Spaces:
Building
Building
Reveal chart curves when they scroll into view
Browse filesAnimate SVG traces once without resampling data, and respect reduced-motion preferences.
Co-authored-by: Codex <noreply@openai.com>
- site/index.html +6 -1
- site/progress.js +24 -0
site/index.html
CHANGED
|
@@ -45,6 +45,11 @@
|
|
| 45 |
.progress h2 { font:400 32px/1.2 var(--lead); margin:0 0 8px; }
|
| 46 |
.progress p { max-width:680px; }
|
| 47 |
#progress-chart { width:100%; height:460px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
@media (max-width:640px) { body { font-size:17px; } #map { height:420px; } #log { height:200px; } .msg { max-width:90%; } }
|
| 49 |
</style>
|
| 50 |
</head>
|
|
@@ -176,6 +181,6 @@ document.getElementById("reset").addEventListener("click", () => {
|
|
| 176 |
q.value = ""; q.disabled = send.disabled = false; q.focus({preventScroll:true});
|
| 177 |
});
|
| 178 |
</script>
|
| 179 |
-
<script src="progress.js?v=
|
| 180 |
</body>
|
| 181 |
</html>
|
|
|
|
| 45 |
.progress h2 { font:400 32px/1.2 var(--lead); margin:0 0 8px; }
|
| 46 |
.progress p { max-width:680px; }
|
| 47 |
#progress-chart { width:100%; height:460px; }
|
| 48 |
+
#progress-chart.awaiting-reveal .scatterlayer .trace { clip-path:inset(-10px 100% -10px 0); }
|
| 49 |
+
#progress-chart.awaiting-reveal { pointer-events:none; }
|
| 50 |
+
@media (prefers-reduced-motion:reduce) {
|
| 51 |
+
#progress-chart.awaiting-reveal .scatterlayer .trace { clip-path:none; }
|
| 52 |
+
}
|
| 53 |
@media (max-width:640px) { body { font-size:17px; } #map { height:420px; } #log { height:200px; } .msg { max-width:90%; } }
|
| 54 |
</style>
|
| 55 |
</head>
|
|
|
|
| 181 |
q.value = ""; q.disabled = send.disabled = false; q.focus({preventScroll:true});
|
| 182 |
});
|
| 183 |
</script>
|
| 184 |
+
<script src="progress.js?v=scroll-reveal-1"></script>
|
| 185 |
</body>
|
| 186 |
</html>
|
site/progress.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
async function drawProgress() {
|
| 2 |
const status = document.getElementById("progress-status");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
try {
|
| 4 |
const response = await fetch("api/progress", {cache: "no-store"});
|
| 5 |
if (!response.ok) throw new Error("Progress data unavailable");
|
|
@@ -47,6 +51,26 @@ async function drawProgress() {
|
|
| 47 |
hoverlabel: {bgcolor: "#ffffff", bordercolor: "#000000"}
|
| 48 |
}, {responsive: true, displayModeBar: false, displaylogo: false});
|
| 49 |
status.hidden = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
} catch (error) {
|
| 51 |
document.getElementById("progress-chart").hidden = true;
|
| 52 |
status.textContent = "The progress chart is currently unavailable.";
|
|
|
|
| 1 |
async function drawProgress() {
|
| 2 |
const status = document.getElementById("progress-status");
|
| 3 |
+
const chart = document.getElementById("progress-chart");
|
| 4 |
+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
| 5 |
+
const animateOnScroll = !reducedMotion.matches && "IntersectionObserver" in window;
|
| 6 |
+
if (animateOnScroll) chart.classList.add("awaiting-reveal");
|
| 7 |
try {
|
| 8 |
const response = await fetch("api/progress", {cache: "no-store"});
|
| 9 |
if (!response.ok) throw new Error("Progress data unavailable");
|
|
|
|
| 51 |
hoverlabel: {bgcolor: "#ffffff", bordercolor: "#000000"}
|
| 52 |
}, {responsive: true, displayModeBar: false, displaylogo: false});
|
| 53 |
status.hidden = true;
|
| 54 |
+
if (animateOnScroll) {
|
| 55 |
+
const observer = new IntersectionObserver(entries => {
|
| 56 |
+
if (!entries.some(entry => entry.isIntersecting && entry.intersectionRatio >= 0.3)) return;
|
| 57 |
+
observer.disconnect();
|
| 58 |
+
// Reveal the rendered SVG traces, keeping both axes and all data fixed.
|
| 59 |
+
// This avoids redrawing thousands of points on every animation frame.
|
| 60 |
+
chart.classList.remove("awaiting-reveal");
|
| 61 |
+
if (reducedMotion.matches) return;
|
| 62 |
+
const animations = [...chart.querySelectorAll(".scatterlayer .trace")].map(trace =>
|
| 63 |
+
trace.animate([
|
| 64 |
+
{clipPath: "inset(-10px 100% -10px 0)"},
|
| 65 |
+
{clipPath: "inset(-10px 0% -10px 0)"}
|
| 66 |
+
], {duration: 1800, easing: "cubic-bezier(0.22, 1, 0.36, 1)"}));
|
| 67 |
+
const finish = () => animations.forEach(animation => animation.finish());
|
| 68 |
+
reducedMotion.addEventListener("change", finish, {once: true});
|
| 69 |
+
Promise.all(animations.map(animation => animation.finished)).finally(() =>
|
| 70 |
+
reducedMotion.removeEventListener("change", finish));
|
| 71 |
+
}, {threshold: 0.3});
|
| 72 |
+
observer.observe(chart);
|
| 73 |
+
}
|
| 74 |
} catch (error) {
|
| 75 |
document.getElementById("progress-chart").hidden = true;
|
| 76 |
status.textContent = "The progress chart is currently unavailable.";
|