From 0f99152f418a867a3e0dacd92670757b7e15bedb Mon Sep 17 00:00:00 2001 From: AIRO Coding Agent Date: Thu, 22 Oct 2026 22:28:57 +0000 Subject: [PATCH] Anchor timeline forecast tooltips to their SVG dots with Tippy --- index.html | 46 +++++++++++++++++++++++------------ web/demo/19-live-timeline.jsx | 46 +++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/index.html b/index.html index fc86178..81418a4 100644 --- a/index.html +++ b/index.html @@ -490,6 +490,28 @@ })).filter(v => v.vals.some(val => val != null)); }; +// React owns the content; Tippy owns placement relative to the actual SVG dot. +function TimelinePointTooltip({ anchor, children }) { + const [content] = useState(() => document.createElement("div")); + const tooltip = React.useRef(null); + React.useEffect(() => { + if (!anchor || !window.tippy) return; + const instance = window.tippy(anchor, { + content, trigger: "manual", theme: "light-border", arrow: true, + maxWidth: 340, offset: [0, 8], placement: "top", appendTo: () => document.body, + animation: false, + }); + tooltip.current = instance; + instance.show(); + return () => { instance.destroy(); tooltip.current = null; }; + }, [anchor, content]); + // Scale changes can move a pinned dot without changing its DOM element. + React.useLayoutEffect(() => { + if (tooltip.current && tooltip.current.popperInstance) tooltip.current.popperInstance.update(); + }); + return ReactDOM.createPortal(children, content); +} + /* ---------------- time-series chart ---------------- */ // Before Oct 21 only the median is visible until hovered or selected; then // model answers appear as ink-gray circles. Since Oct 21 each prompt condition @@ -542,17 +564,17 @@ const h = horizon; const med = q.median[h] || []; const fmtV = tlFmtFor(q); - const [active, setActive] = useState(null); // { i, id }, null id = median + const [active, setActive] = useState(null); // { i, id, anchor }, null id = median const [pinned, setPinned] = useState(false); // click/tap keeps the details open const hv = active ? active.i : null; const hvar = active && active.id != null ? active : null; const clear = () => { setActive(null); setPinned(false); }; - const hover = (i, id = null) => { if (!pinned) setActive({ i, id }); }; + const hover = (e, i, id = null) => { if (!pinned) setActive({ i, id, anchor: e.currentTarget.lastElementChild }); }; const leave = () => { if (!pinned) setActive(null); }; const select = (e, i, id = null) => { e.stopPropagation(); if (pinned && hv === i && (active && active.id) === id) clear(); - else { setActive({ i, id }); setPinned(true); } + else { setActive({ i, id, anchor: e.currentTarget.lastElementChild }); setPinned(true); } }; const keySelect = (e, i, id = null) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(e, i, id); } @@ -613,12 +635,6 @@ const li = med.map((v, i) => (v != null ? i : -1)).reduce((a, b) => Math.max(a, b), -1); // Right-margin label: the latest median, kept above the date labels. const endLabel = li >= 0 ? { v: med[li], y: Math.min(y(med[li]), H - pad.b - 2), xi: li } : null; - // A hover card beside date i, flipped left in the right 40%. - const tipStyle = i => ({ ...(narrow ? { position: "relative", marginTop: 8 } : { - position: "absolute", top: 8, left: `${(x(i) / W) * 100}%`, - transform: x(i) > W * 0.6 ? "translateX(calc(-100% - 8px))" : "translateX(10px)" }), - background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 8, padding: "9px 11px", - boxShadow: "var(--shadow)", pointerEvents: "none", maxWidth: narrow ? "100%" : 340 }); return (
{ if (e.key === "Escape") clear(); }}> @@ -680,7 +696,7 @@ hover(i)} onMouseLeave={leave} onFocus={() => hover(i)} onBlur={leave} + onMouseEnter={e => hover(e, i)} onMouseLeave={leave} onFocus={e => hover(e, i)} onBlur={leave} onClick={e => select(e, i)} onKeyDown={e => keySelect(e, i)}> @@ -693,7 +709,7 @@ role="button" tabIndex={0} style={{ cursor: "pointer" }} aria-label={`${snapshots[i].date}, ${v.label} prompt ${fmtV(v.vals[i])}; show model forecasts`} aria-pressed={pinned && hv === i && hvar && hvar.id === v.id ? true : false} - onMouseEnter={() => hover(i, v.id)} onMouseLeave={leave} onFocus={() => hover(i, v.id)} onBlur={leave} + onMouseEnter={e => hover(e, i, v.id)} onMouseLeave={leave} onFocus={e => hover(e, i, v.id)} onBlur={leave} onClick={e => select(e, i, v.id)} onKeyDown={e => keySelect(e, i, v.id)}> @@ -702,7 +718,7 @@ {hovered && ( -
+ {hoveredModels.map(([label, val]) => (
@@ -711,10 +727,10 @@ {fmtV(val)}
))} -
+ )} {hv != null && !hovered && ( -
+
{snapshots[hv].date} · by {h} {hasVar(hv) ? ` · ${variants.filter(v => v.vals[hv] != null).length} prompts per model · each model = mean of its prompts` @@ -744,7 +760,7 @@
))} -
+ )}
); diff --git a/web/demo/19-live-timeline.jsx b/web/demo/19-live-timeline.jsx index cbbc01f..5fb2527 100644 --- a/web/demo/19-live-timeline.jsx +++ b/web/demo/19-live-timeline.jsx @@ -49,6 +49,28 @@ const tlVariants = (q, horizon, snapshots) => { })).filter(v => v.vals.some(val => val != null)); }; +// React owns the content; Tippy owns placement relative to the actual SVG dot. +function TimelinePointTooltip({ anchor, children }) { + const [content] = useState(() => document.createElement("div")); + const tooltip = React.useRef(null); + React.useEffect(() => { + if (!anchor || !window.tippy) return; + const instance = window.tippy(anchor, { + content, trigger: "manual", theme: "light-border", arrow: true, + maxWidth: 340, offset: [0, 8], placement: "top", appendTo: () => document.body, + animation: false, + }); + tooltip.current = instance; + instance.show(); + return () => { instance.destroy(); tooltip.current = null; }; + }, [anchor, content]); + // Scale changes can move a pinned dot without changing its DOM element. + React.useLayoutEffect(() => { + if (tooltip.current && tooltip.current.popperInstance) tooltip.current.popperInstance.update(); + }); + return ReactDOM.createPortal(children, content); +} + /* ---------------- time-series chart ---------------- */ // Before Oct 21 only the median is visible until hovered or selected; then // model answers appear as ink-gray circles. Since Oct 21 each prompt condition @@ -101,17 +123,17 @@ function TimelineChart({ q, snapshots, horizon, scale }) { const h = horizon; const med = q.median[h] || []; const fmtV = tlFmtFor(q); - const [active, setActive] = useState(null); // { i, id }, null id = median + const [active, setActive] = useState(null); // { i, id, anchor }, null id = median const [pinned, setPinned] = useState(false); // click/tap keeps the details open const hv = active ? active.i : null; const hvar = active && active.id != null ? active : null; const clear = () => { setActive(null); setPinned(false); }; - const hover = (i, id = null) => { if (!pinned) setActive({ i, id }); }; + const hover = (e, i, id = null) => { if (!pinned) setActive({ i, id, anchor: e.currentTarget.lastElementChild }); }; const leave = () => { if (!pinned) setActive(null); }; const select = (e, i, id = null) => { e.stopPropagation(); if (pinned && hv === i && (active && active.id) === id) clear(); - else { setActive({ i, id }); setPinned(true); } + else { setActive({ i, id, anchor: e.currentTarget.lastElementChild }); setPinned(true); } }; const keySelect = (e, i, id = null) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(e, i, id); } @@ -172,12 +194,6 @@ function TimelineChart({ q, snapshots, horizon, scale }) { const li = med.map((v, i) => (v != null ? i : -1)).reduce((a, b) => Math.max(a, b), -1); // Right-margin label: the latest median, kept above the date labels. const endLabel = li >= 0 ? { v: med[li], y: Math.min(y(med[li]), H - pad.b - 2), xi: li } : null; - // A hover card beside date i, flipped left in the right 40%. - const tipStyle = i => ({ ...(narrow ? { position: "relative", marginTop: 8 } : { - position: "absolute", top: 8, left: `${(x(i) / W) * 100}%`, - transform: x(i) > W * 0.6 ? "translateX(calc(-100% - 8px))" : "translateX(10px)" }), - background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 8, padding: "9px 11px", - boxShadow: "var(--shadow)", pointerEvents: "none", maxWidth: narrow ? "100%" : 340 }); return (
{ if (e.key === "Escape") clear(); }}> @@ -239,7 +255,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) { hover(i)} onMouseLeave={leave} onFocus={() => hover(i)} onBlur={leave} + onMouseEnter={e => hover(e, i)} onMouseLeave={leave} onFocus={e => hover(e, i)} onBlur={leave} onClick={e => select(e, i)} onKeyDown={e => keySelect(e, i)}> @@ -252,7 +268,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) { role="button" tabIndex={0} style={{ cursor: "pointer" }} aria-label={`${snapshots[i].date}, ${v.label} prompt ${fmtV(v.vals[i])}; show model forecasts`} aria-pressed={pinned && hv === i && hvar && hvar.id === v.id ? true : false} - onMouseEnter={() => hover(i, v.id)} onMouseLeave={leave} onFocus={() => hover(i, v.id)} onBlur={leave} + onMouseEnter={e => hover(e, i, v.id)} onMouseLeave={leave} onFocus={e => hover(e, i, v.id)} onBlur={leave} onClick={e => select(e, i, v.id)} onKeyDown={e => keySelect(e, i, v.id)}> @@ -261,7 +277,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) { {hovered && ( -
+ {hoveredModels.map(([label, val]) => (
@@ -270,10 +286,10 @@ function TimelineChart({ q, snapshots, horizon, scale }) { {fmtV(val)}
))} -
+ )} {hv != null && !hovered && ( -
+
{snapshots[hv].date} · by {h} {hasVar(hv) ? ` · ${variants.filter(v => v.vals[hv] != null).length} prompts per model · each model = mean of its prompts` @@ -303,7 +319,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) {
))} -
+ )}
);