Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/open-rocks-move.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 18 additions & 13 deletions packages/dvz-ui/src/embeddable/chart/LineLayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
timothygachengo marked this conversation as resolved.
}
}}
onMouseOut={(event) => tooltip.style("visibility", "hidden")}
Expand Down
215 changes: 158 additions & 57 deletions packages/dvz-ui/src/embeddable/chart/Tooltip.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Comment thread
timothygachengo marked this conversation as resolved.
}

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;
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -69,65 +169,66 @@ export const formatContent = (
};

const Tooltip = ({ tooltip, d, intl, tooltipEnableMarkdown }) => {
// Guard against undefined/null values during SSR
if (!d || !tooltip) {
return <div></div>;
}
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 <div></div>;
}
if (data.measureFieldName && data.variables) {
params.populationValue =
data.variables[data.measureFieldName + "Population"];
}

if (tooltipEnableMarkdown) {
return (
<div className={"chart tooltip"}>
<ReactMarkdown
children={str}
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
></ReactMarkdown>
</div>
);
} else {
return (
<div className={"chart tooltip"}>
<div dangerouslySetInnerHTML={{ __html: str }}></div>
</div>
);
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 <div></div>;
}

if (tooltipEnableMarkdown) {
return (
<div ref={tooltipRef} className={"chart tooltip"}>
<ReactMarkdown
children={str}
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
></ReactMarkdown>
</div>
);
} else {
return (
<div ref={tooltipRef} className={"chart tooltip"}>
<div dangerouslySetInnerHTML={{ __html: str }}></div>
</div>
);
}
};

export default Tooltip;
14 changes: 11 additions & 3 deletions packages/dvz-ui/src/embeddable/common/ChartTooltip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,24 +39,31 @@ 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

const params = { field: d.point ? d.point.serieId : d.id, ...vars, value: current }
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 (
<div className={"chart tooltip"}>
<div ref={tooltipRef} className={"chart tooltip"}>
<ReactMarkdown children={str} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
</ReactMarkdown>
</div>
)
} else {
return (
<div className={"chart tooltip"} >
<div ref={tooltipRef} className={"chart tooltip"} >
<div dangerouslySetInnerHTML={{ __html: str }}></div>
</div>)
}
Expand Down
Loading
Loading