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 }) {
))}
-
+
)}
);