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
46 changes: 31 additions & 15 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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 (
<div ref={box} style={{ position: "relative", minWidth: 0 }} onKeyDown={e => { if (e.key === "Escape") clear(); }}>
Expand Down Expand Up @@ -680,7 +696,7 @@
<g key={"m" + i} role="button" tabIndex={0} style={{ cursor: "pointer" }}
aria-label={`${snapshots[i].date}, ensemble median ${fmtV(v)}; show model forecasts`}
aria-pressed={pinned && hv === i && !hvar}
onMouseEnter={() => 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)}>
<circle cx={x(i)} cy={y(v)} r="10" fill="transparent" />
<circle cx={x(i)} cy={y(v)} r="5" fill="var(--panel)" stroke="var(--ink)" strokeWidth="2.6" />
Expand All @@ -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)}>
<circle cx={cx} cy={cy} r="10" fill="transparent" />
<circle cx={cx} cy={cy} r="4.5" fill={v.color} stroke="var(--panel)" strokeWidth="1.5" />
Expand All @@ -702,7 +718,7 @@
</svg>

{hovered && (
<div style={tipStyle(hvar.i)}>
<TimelinePointTooltip anchor={active.anchor}>
{hoveredModels.map(([label, val]) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", gap: 14, fontSize: 12 }}>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
Expand All @@ -711,10 +727,10 @@
<span className="mono">{fmtV(val)}</span>
</div>
))}
</div>
</TimelinePointTooltip>
)}
{hv != null && !hovered && (
<div style={tipStyle(hv)}>
<TimelinePointTooltip anchor={active.anchor}>
<div style={{ fontSize: 11, color: "var(--ink-faint)", marginBottom: 6 }}>
{snapshots[hv].date} · <span style={{ color: "var(--ink)", fontWeight: 600 }}>by {h}</span>
{hasVar(hv) ? ` · ${variants.filter(v => v.vals[hv] != null).length} prompts per model · each model = mean of its prompts`
Expand Down Expand Up @@ -744,7 +760,7 @@
</span>
</div>
))}
</div>
</TimelinePointTooltip>
)}
</div>
);
Expand Down
46 changes: 31 additions & 15 deletions web/demo/19-live-timeline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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 (
<div ref={box} style={{ position: "relative", minWidth: 0 }} onKeyDown={e => { if (e.key === "Escape") clear(); }}>
Expand Down Expand Up @@ -239,7 +255,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) {
<g key={"m" + i} role="button" tabIndex={0} style={{ cursor: "pointer" }}
aria-label={`${snapshots[i].date}, ensemble median ${fmtV(v)}; show model forecasts`}
aria-pressed={pinned && hv === i && !hvar}
onMouseEnter={() => 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)}>
<circle cx={x(i)} cy={y(v)} r="10" fill="transparent" />
<circle cx={x(i)} cy={y(v)} r="5" fill="var(--panel)" stroke="var(--ink)" strokeWidth="2.6" />
Expand All @@ -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)}>
<circle cx={cx} cy={cy} r="10" fill="transparent" />
<circle cx={cx} cy={cy} r="4.5" fill={v.color} stroke="var(--panel)" strokeWidth="1.5" />
Expand All @@ -261,7 +277,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) {
</svg>

{hovered && (
<div style={tipStyle(hvar.i)}>
<TimelinePointTooltip anchor={active.anchor}>
{hoveredModels.map(([label, val]) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", gap: 14, fontSize: 12 }}>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
Expand All @@ -270,10 +286,10 @@ function TimelineChart({ q, snapshots, horizon, scale }) {
<span className="mono">{fmtV(val)}</span>
</div>
))}
</div>
</TimelinePointTooltip>
)}
{hv != null && !hovered && (
<div style={tipStyle(hv)}>
<TimelinePointTooltip anchor={active.anchor}>
<div style={{ fontSize: 11, color: "var(--ink-faint)", marginBottom: 6 }}>
{snapshots[hv].date} · <span style={{ color: "var(--ink)", fontWeight: 600 }}>by {h}</span>
{hasVar(hv) ? ` · ${variants.filter(v => v.vals[hv] != null).length} prompts per model · each model = mean of its prompts`
Expand Down Expand Up @@ -303,7 +319,7 @@ function TimelineChart({ q, snapshots, horizon, scale }) {
</span>
</div>
))}
</div>
</TimelinePointTooltip>
)}
</div>
);
Expand Down
Loading