From 733c8fd73613d9791f678f5f2b9c3d4080623e12 Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Mon, 20 Jul 2026 20:37:33 +0300 Subject: [PATCH 1/7] fix: make tooltips to not appear off screen --- packages/dvz-ui/package.json | 1 + .../dvz-ui/src/embeddable/chart/LineLayer.jsx | 31 +-- .../dvz-ui/src/embeddable/chart/Tooltip.jsx | 180 +++++++++++++----- .../src/embeddable/common/ChartTooltip.jsx | 14 +- 4 files changed, 160 insertions(+), 66 deletions(-) diff --git a/packages/dvz-ui/package.json b/packages/dvz-ui/package.json index 33ec5f7c..07909930 100644 --- a/packages/dvz-ui/package.json +++ b/packages/dvz-ui/package.json @@ -84,6 +84,7 @@ "react-markdown": "^10.1.0", "react-redux": "~9.2.0", "react-router": "~7.9.4", + "react-tooltip": "^6.0.8", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "rehype-raw": "^7.0.0", diff --git a/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx b/packages/dvz-ui/src/embeddable/chart/LineLayer.jsx index e5e393df..06063c77 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 c5fff65b..72f2c28d 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,6 +8,83 @@ const percentExpresion = /(\+?\%)[\(]([A-z0-9,.,-]+)\)/gi; const numericExpresion = /(\+?\#)[\(]([A-z0-9,.,-]+)\)/gi; const compactExpresion = /(\+?\#C)[\(]([A-z0-9,.,-]+)\)/gi; +// Minimum distance (in px) to keep between the tooltip and the edge of the viewport. +const VIEWPORT_EDGE_MARGIN = 8; + +// Nudges an already-positioned element back within the viewport bounds. +// Chart libraries (e.g. nivo) position tooltips relative to the data point, +// which can push them past the left/right/top/bottom edge of the screen - +// most noticeably on narrow/mobile viewports or when the point is near an edge. +// This measures the rendered element and applies a corrective local transform, +// without altering the positioning logic of the chart library itself. +export const clampTooltipToViewport = (el) => { + if (!el || typeof window === "undefined") { + return; + } + + // Reset any previous adjustment before measuring so we always compute + // the correction relative to the library's original position. + el.style.transform = ""; + + const rect = el.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const isMobile = viewportWidth < 768; // Adjust this threshold as needed + + let shiftX = 0; + if (rect.left < VIEWPORT_EDGE_MARGIN) { + shiftX = VIEWPORT_EDGE_MARGIN - rect.left; + } else if (rect.right > viewportWidth - VIEWPORT_EDGE_MARGIN) { + shiftX = viewportWidth - VIEWPORT_EDGE_MARGIN - rect.right - (isMobile ? 12 : 40); // Adjust for mobile if needed + } + + let shiftY = 0; + if (rect.top < VIEWPORT_EDGE_MARGIN) { + shiftY = VIEWPORT_EDGE_MARGIN - rect.top; + } else if (rect.bottom > viewportHeight - VIEWPORT_EDGE_MARGIN) { + shiftY = viewportHeight - VIEWPORT_EDGE_MARGIN - rect.bottom; + } + + if (shiftX || shiftY) { + el.style.transform = `translate(${Math.round(shiftX)}px, ${Math.round(shiftY)}px)`; + } +}; + +// Hook that keeps a tooltip element within the viewport whenever its +// content (and therefore size/position) changes. +// +// The parent tooltip wrapper (e.g. nivo's TooltipWrapper) animates its +// position with react-spring, so a single one-off measurement can catch it +// mid-animation and "freeze" a correction that overshoots once the +// animation settles. To avoid that, we keep re-measuring on every animation +// frame for as long as the tooltip is mounted, so the applied correction +// always matches the wrapper's current (not intermediate) position. +export const useClampTooltipToViewport = (deps = []) => { + const ref = useRef(null); + + useLayoutEffect(() => { + if (typeof window === "undefined" || !window.requestAnimationFrame) { + clampTooltipToViewport(ref.current); + return undefined; + } + + let frameId; + const tick = () => { + clampTooltipToViewport(ref.current); + frameId = window.requestAnimationFrame(tick); + }; + frameId = window.requestAnimationFrame(tick); + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId); + } + }; + }, 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 if (!intl || !intl.formatNumber) { @@ -69,65 +146,68 @@ export const formatContent = ( }; const Tooltip = ({ tooltip, d, intl, tooltipEnableMarkdown }) => { + let str = ""; + // Guard against undefined/null values during SSR - if (!d || !tooltip) { - return
; - } + if (d && tooltip) { + const datum = d.datum || d.point || d; + const { data } = datum || {}; - 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 (data) { + const current = + d.value || + (d.datum ? d.datum.value : null) || + (d.point ? d.point.data.y : null); - const params = { - field: d.point ? d.point.serieId : (d.id || ''), - ...vars, - value: current, - }; + let vars; + if (data.variables) { + vars = typeof data.variables[d.id] === 'object' + ? data.variables[d.id] + : data.variables; + } else { + vars = data; + } - if (data.measureFieldName && data.variables) { - params.populationValue = - data.variables[data.measureFieldName + "Population"]; - } + const params = { + field: d.point ? d.point.serieId : (d.id || ''), + ...vars, + value: current, + }; - const str = formatContent(tooltip, params, intl, tooltipEnableMarkdown); + if (data.measureFieldName && data.variables) { + params.populationValue = + data.variables[data.measureFieldName + "Population"]; + } - if (!str) { - return
; + str = formatContent(tooltip, params, intl, tooltipEnableMarkdown); } + } - if (tooltipEnableMarkdown) { - return ( -
- -
- ); - } else { - return ( -
-
-
- ); - } - } else { + // Rules of hooks require this to run on every render, before any + // conditional early returns based on the computed content. + 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 60692d1b..660f82e1 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 ( -
+
) } From a4cd71672a7cfee8c1a317d43327ae2361ec0f91 Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Mon, 20 Jul 2026 21:59:09 +0300 Subject: [PATCH 2/7] chore: add changeset --- .changeset/open-rocks-move.md | 8 +++++++ pnpm-lock.yaml | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .changeset/open-rocks-move.md diff --git a/.changeset/open-rocks-move.md b/.changeset/open-rocks-move.md new file mode 100644 index 00000000..2534cb18 --- /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/pnpm-lock.yaml b/pnpm-lock.yaml index 30da7700..808fb100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: react-router: specifier: ~7.9.4 version: 7.9.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-tooltip: + specifier: ^6.0.8 + version: 6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: specifier: ^5.0.1 version: 5.0.1 @@ -975,15 +978,24 @@ packages: '@floating-ui/core@1.7.0': resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.0': resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.2': resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} @@ -2254,6 +2266,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==} @@ -4612,6 +4625,12 @@ packages: react: '>=16.14.0' react-dom: '>=16.14.0' + react-tooltip@6.0.8: + resolution: {integrity: sha512-VPjtBPyoFwq72QVE6GYB/LtfEg/mnFBHqL0nD0oNNmjH8EZYFyBwgnFKNCOp5zCte45k1weXjKc0wXE6NICpfg==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -5127,6 +5146,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 +5291,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: @@ -6125,17 +6146,28 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.9 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.0': dependencies: '@floating-ui/core': 1.7.0 '@floating-ui/utils': 0.2.9 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/utils@0.2.12': {} + '@floating-ui/utils@0.2.9': {} '@fluentui/react-component-event-listener@0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -10450,6 +10482,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-tooltip@6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@floating-ui/dom': 1.7.6 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 From c7dbd7134bc879136cdbca2fd51d9ce6f88933db Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Tue, 21 Jul 2026 10:33:10 +0300 Subject: [PATCH 3/7] chore: apply ai suggestions --- packages/dvz-ui/package.json | 1 - .../dvz-ui/src/embeddable/chart/Tooltip.jsx | 85 ++++++++++++++++--- .../src/scss/themes/default/_tabbed.scss | 8 +- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/dvz-ui/package.json b/packages/dvz-ui/package.json index 07909930..33ec5f7c 100644 --- a/packages/dvz-ui/package.json +++ b/packages/dvz-ui/package.json @@ -84,7 +84,6 @@ "react-markdown": "^10.1.0", "react-redux": "~9.2.0", "react-router": "~7.9.4", - "react-tooltip": "^6.0.8", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "rehype-raw": "^7.0.0", diff --git a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx index 72f2c28d..fb1a659d 100644 --- a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx +++ b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx @@ -50,36 +50,99 @@ export const clampTooltipToViewport = (el) => { } }; +// Number of consecutive frames with an unchanged correction before we +// consider the tooltip settled and stop the rAF burst. +const SETTLE_FRAMES = 6; +// Hard cap on a single burst (~1s at 60fps) as a safety net in case the +// wrapper's position never truly stabilizes (e.g. a very long animation). +const MAX_BURST_FRAMES = 60; + // Hook that keeps a tooltip element within the viewport whenever its -// content (and therefore size/position) changes. +// content, size or the viewport itself changes. // // The parent tooltip wrapper (e.g. nivo's TooltipWrapper) animates its // position with react-spring, so a single one-off measurement can catch it // mid-animation and "freeze" a correction that overshoots once the -// animation settles. To avoid that, we keep re-measuring on every animation -// frame for as long as the tooltip is mounted, so the applied correction -// always matches the wrapper's current (not intermediate) position. +// animation settles. To handle that without keeping an rAF loop running +// indefinitely (which would burn main-thread time for as long as any +// tooltip stays mounted), we only re-measure on every frame for a short +// "burst" - stopping once the applied correction stops changing for a few +// consecutive frames. A ResizeObserver on the element (content/size +// changes) and window scroll/resize listeners restart a fresh burst +// whenever the tooltip could have moved again afterwards. export const useClampTooltipToViewport = (deps = []) => { const ref = useRef(null); useLayoutEffect(() => { + const el = ref.current; + if (!el) { + return undefined; + } + if (typeof window === "undefined" || !window.requestAnimationFrame) { - clampTooltipToViewport(ref.current); + clampTooltipToViewport(el); return undefined; } - let frameId; + let frameId = null; + let lastTransform = null; + let unchangedFrames = 0; + let framesRun = 0; + + const stopBurst = () => { + if (frameId != null) { + window.cancelAnimationFrame(frameId); + frameId = null; + } + }; + const tick = () => { - clampTooltipToViewport(ref.current); + clampTooltipToViewport(el); + framesRun += 1; + + if (el.style.transform === lastTransform) { + unchangedFrames += 1; + } else { + unchangedFrames = 0; + lastTransform = el.style.transform; + } + + if (unchangedFrames >= SETTLE_FRAMES || framesRun >= MAX_BURST_FRAMES) { + frameId = null; + return; + } + + frameId = window.requestAnimationFrame(tick); + }; + + const startBurst = () => { + stopBurst(); + lastTransform = null; + unchangedFrames = 0; + framesRun = 0; frameId = window.requestAnimationFrame(tick); }; - frameId = window.requestAnimationFrame(tick); + + startBurst(); + + let resizeObserver; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(startBurst); + resizeObserver.observe(el); + } + + // Capture phase so scrolling any ancestor container (not just the + // window) also triggers a re-clamp. + window.addEventListener("scroll", startBurst, true); + window.addEventListener("resize", startBurst); return () => { - if (frameId) { - window.cancelAnimationFrame(frameId); - } + stopBurst(); + resizeObserver?.disconnect(); + window.removeEventListener("scroll", startBurst, true); + window.removeEventListener("resize", startBurst); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return ref; diff --git a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss index bc487696..8e3b1302 100644 --- a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss +++ b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss @@ -52,7 +52,13 @@ padding: 0; margin: 0; .sixteen.wide.column.content { - overflow: hidden; + // Was `overflow: hidden`, which clipped absolutely-positioned chart + // tooltips (nivo does not portal its tooltip - it renders in place) + // whenever they extended past this container's edges, e.g. near the + // top/bottom of a tab panel. Visible keeps rounded-corner clipping + // from `.row` from being pixel-perfect at the very corners, but that + // is a much smaller visual issue than a cut-off tooltip. + overflow: visible; padding: 0; .entry-content { padding: 25px 33px 0 33px; From 0a706ffb41a677f358fc88c94cbfc98f0435f8f4 Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Tue, 21 Jul 2026 10:39:43 +0300 Subject: [PATCH 4/7] chore: apply ai suggestions --- pnpm-lock.yaml | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 808fb100..f31122c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,9 +247,6 @@ importers: react-router: specifier: ~7.9.4 version: 7.9.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-tooltip: - specifier: ^6.0.8 - version: 6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: specifier: ^5.0.1 version: 5.0.1 @@ -978,24 +975,15 @@ packages: '@floating-ui/core@1.7.0': resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} - '@floating-ui/core@1.8.0': - resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.0': resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.2': resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.12': - resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} @@ -4625,12 +4613,6 @@ packages: react: '>=16.14.0' react-dom: '>=16.14.0' - react-tooltip@6.0.8: - resolution: {integrity: sha512-VPjtBPyoFwq72QVE6GYB/LtfEg/mnFBHqL0nD0oNNmjH8EZYFyBwgnFKNCOp5zCte45k1weXjKc0wXE6NICpfg==} - peerDependencies: - react: '>=16.14.0' - react-dom: '>=16.14.0' - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -6146,28 +6128,17 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.9 - '@floating-ui/core@1.8.0': - dependencies: - '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.0': dependencies: '@floating-ui/core': 1.7.0 '@floating-ui/utils': 0.2.9 - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.8.0 - '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@floating-ui/utils@0.2.12': {} - '@floating-ui/utils@0.2.9': {} '@fluentui/react-component-event-listener@0.63.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -10482,13 +10453,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-tooltip@6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@floating-ui/dom': 1.7.6 - clsx: 2.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 From 939be3da2c418e3e58547a5cfdcf613d4c5a111f Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Tue, 21 Jul 2026 11:43:32 +0300 Subject: [PATCH 5/7] chore: apply css --- packages/dvz-ui/src/scss/themes/default/_tabbed.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss index 8e3b1302..9365f022 100644 --- a/packages/dvz-ui/src/scss/themes/default/_tabbed.scss +++ b/packages/dvz-ui/src/scss/themes/default/_tabbed.scss @@ -52,12 +52,6 @@ padding: 0; margin: 0; .sixteen.wide.column.content { - // Was `overflow: hidden`, which clipped absolutely-positioned chart - // tooltips (nivo does not portal its tooltip - it renders in place) - // whenever they extended past this container's edges, e.g. near the - // top/bottom of a tab panel. Visible keeps rounded-corner clipping - // from `.row` from being pixel-perfect at the very corners, but that - // is a much smaller visual issue than a cut-off tooltip. overflow: visible; padding: 0; .entry-content { From 2b677888453afa4b6963b230ba332b3025ea075f Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Fri, 24 Jul 2026 07:59:03 +0300 Subject: [PATCH 6/7] fix: center tooltips and prevent off-screen display --- packages/dvz-ui/src/embeddable/map/map.jsx | 37 +++++++++++++++------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/dvz-ui/src/embeddable/map/map.jsx b/packages/dvz-ui/src/embeddable/map/map.jsx index 2af8b180..20393c21 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 From a4a9c79174b94edf7942903b37f554e0e7202605 Mon Sep 17 00:00:00 2001 From: Timothy Mugo Date: Fri, 24 Jul 2026 08:45:15 +0300 Subject: [PATCH 7/7] fix: enhance tooltip styling for better readability and prevent interaction --- .../dvz-ui/src/embeddable/chart/Tooltip.jsx | 142 ++++++------------ packages/dvz-ui/src/scss/common.scss | 10 ++ 2 files changed, 60 insertions(+), 92 deletions(-) diff --git a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx index fb1a659d..197bb7ec 100644 --- a/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx +++ b/packages/dvz-ui/src/embeddable/chart/Tooltip.jsx @@ -8,150 +8,112 @@ const percentExpresion = /(\+?\%)[\(]([A-z0-9,.,-]+)\)/gi; const numericExpresion = /(\+?\#)[\(]([A-z0-9,.,-]+)\)/gi; const compactExpresion = /(\+?\#C)[\(]([A-z0-9,.,-]+)\)/gi; -// Minimum distance (in px) to keep between the tooltip and the edge of the viewport. +// Minimum gap (px) to keep between the tooltip and the viewport edge. const VIEWPORT_EDGE_MARGIN = 8; -// Nudges an already-positioned element back within the viewport bounds. -// Chart libraries (e.g. nivo) position tooltips relative to the data point, -// which can push them past the left/right/top/bottom edge of the screen - -// most noticeably on narrow/mobile viewports or when the point is near an edge. -// This measures the rendered element and applies a corrective local transform, -// without altering the positioning logic of the chart library itself. +// 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 any previous adjustment before measuring so we always compute - // the correction relative to the library's original position. + // Reset so we measure relative to the library's original position. el.style.transform = ""; const rect = el.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const isMobile = viewportWidth < 768; // Adjust this threshold as needed - - let shiftX = 0; - if (rect.left < VIEWPORT_EDGE_MARGIN) { - shiftX = VIEWPORT_EDGE_MARGIN - rect.left; - } else if (rect.right > viewportWidth - VIEWPORT_EDGE_MARGIN) { - shiftX = viewportWidth - VIEWPORT_EDGE_MARGIN - rect.right - (isMobile ? 12 : 40); // Adjust for mobile if needed - } + const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window; + const mobileOvershoot = viewportWidth < 768 ? 12 : 40; // extra room on narrow viewports - let shiftY = 0; - if (rect.top < VIEWPORT_EDGE_MARGIN) { - shiftY = VIEWPORT_EDGE_MARGIN - rect.top; - } else if (rect.bottom > viewportHeight - VIEWPORT_EDGE_MARGIN) { - shiftY = viewportHeight - VIEWPORT_EDGE_MARGIN - rect.bottom; - } + 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)`; } }; -// Number of consecutive frames with an unchanged correction before we -// consider the tooltip settled and stop the rAF burst. +// Frames with an unchanged correction before we consider the tooltip settled. const SETTLE_FRAMES = 6; -// Hard cap on a single burst (~1s at 60fps) as a safety net in case the -// wrapper's position never truly stabilizes (e.g. a very long animation). +// Safety cap on a burst's length (~1s at 60fps) in case position never settles. const MAX_BURST_FRAMES = 60; -// Hook that keeps a tooltip element within the viewport whenever its -// content, size or the viewport itself changes. -// -// The parent tooltip wrapper (e.g. nivo's TooltipWrapper) animates its -// position with react-spring, so a single one-off measurement can catch it -// mid-animation and "freeze" a correction that overshoots once the -// animation settles. To handle that without keeping an rAF loop running -// indefinitely (which would burn main-thread time for as long as any -// tooltip stays mounted), we only re-measure on every frame for a short -// "burst" - stopping once the applied correction stops changing for a few -// consecutive frames. A ResizeObserver on the element (content/size -// changes) and window scroll/resize listeners restart a fresh burst -// whenever the tooltip could have moved again afterwards. +/** + * 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) { - return undefined; - } - - if (typeof window === "undefined" || !window.requestAnimationFrame) { + if (!el || typeof window === "undefined" || !window.requestAnimationFrame) { clampTooltipToViewport(el); return undefined; } let frameId = null; - let lastTransform = null; - let unchangedFrames = 0; - let framesRun = 0; - const stopBurst = () => { + const startBurst = () => { if (frameId != null) { window.cancelAnimationFrame(frameId); - frameId = null; } - }; - const tick = () => { - clampTooltipToViewport(el); - framesRun += 1; + let lastTransform = null; + let unchangedFrames = 0; - if (el.style.transform === lastTransform) { - unchangedFrames += 1; - } else { - unchangedFrames = 0; - lastTransform = el.style.transform; - } + const tick = (frame) => { + clampTooltipToViewport(el); - if (unchangedFrames >= SETTLE_FRAMES || framesRun >= MAX_BURST_FRAMES) { - frameId = null; - return; - } + unchangedFrames = el.style.transform === lastTransform ? unchangedFrames + 1 : 0; + lastTransform = el.style.transform; - frameId = window.requestAnimationFrame(tick); - }; + const settled = unchangedFrames >= SETTLE_FRAMES || frame >= MAX_BURST_FRAMES; + frameId = settled ? null : window.requestAnimationFrame(() => tick(frame + 1)); + }; - const startBurst = () => { - stopBurst(); - lastTransform = null; - unchangedFrames = 0; - framesRun = 0; - frameId = window.requestAnimationFrame(tick); + tick(0); }; startBurst(); - let resizeObserver; - if (typeof ResizeObserver !== "undefined") { - resizeObserver = new ResizeObserver(startBurst); - resizeObserver.observe(el); - } + const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(startBurst) : null; + resizeObserver?.observe(el); - // Capture phase so scrolling any ancestor container (not just the - // window) also triggers a re-clamp. + // Capture phase catches scroll on any ancestor, not just the window. window.addEventListener("scroll", startBurst, true); window.addEventListener("resize", startBurst); return () => { - stopBurst(); + if (frameId != null) { + window.cancelAnimationFrame(frameId); + } resizeObserver?.disconnect(); window.removeEventListener("scroll", startBurst, true); window.removeEventListener("resize", startBurst); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, 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; } @@ -179,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; } @@ -211,7 +171,6 @@ export const formatContent = ( const Tooltip = ({ tooltip, d, intl, tooltipEnableMarkdown }) => { let str = ""; - // Guard against undefined/null values during SSR if (d && tooltip) { const datum = d.datum || d.point || d; const { data } = datum || {}; @@ -246,8 +205,7 @@ const Tooltip = ({ tooltip, d, intl, tooltipEnableMarkdown }) => { } } - // Rules of hooks require this to run on every render, before any - // conditional early returns based on the computed content. + // Must run before any early return below (rules of hooks). const tooltipRef = useClampTooltipToViewport([str, tooltipEnableMarkdown]); if (!str) { diff --git a/packages/dvz-ui/src/scss/common.scss b/packages/dvz-ui/src/scss/common.scss index 1e8795b6..e678ab8b 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;