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
11 changes: 9 additions & 2 deletions js/demo/playground.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,21 @@ function decodeSource(encoded) {
}

let ready = false;
let lastHtml = null;

function renderNow() {
if (!ready) {
return;
}
try {
const html = render(source.value);
mount(output, html);
// Re-mounting tears down and rebuilds every chart and diagram, so skip it
// when an edit did not change the rendered output (whitespace, a typo then
// its fix). Keeps typing smooth on a graphics-heavy document.
if (html !== lastHtml) {
mount(output, html);
lastHtml = html;
}
status.textContent = "";
status.classList.remove("pg-status-error");
} catch (error) {
Expand All @@ -103,7 +110,7 @@ function renderNow() {
let debounce;
function scheduleRender() {
clearTimeout(debounce);
debounce = setTimeout(renderNow, 150);
debounce = setTimeout(renderNow, 220);
}

async function loadPreset(name) {
Expand Down
35 changes: 35 additions & 0 deletions js/e2e/playground.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";

// Guards the playground against the chart-overflow regression: a uPlot canvas
// sized once at mount used to stay wider than its column (a fallback width, or a
// stale measurement taken before a vertical scrollbar narrowed the pane), which
// left a permanent horizontal scrollbar on the preview. The runtime now reflows
// the chart to its container; this proves the preview never overflows sideways,
// at load and after a resize.

// A narrow, short viewport: two columns (above the 720px stack breakpoint) so the
// preview pane is well under the chart's fallback width, and short enough that the
// document needs a vertical scrollbar, reproducing the width-shrink that bit us.
const NARROW = { width: 760, height: 520 };

async function horizontalOverflow(page: import("@playwright/test").Page): Promise<number> {
return page.locator("#out").evaluate((el) => el.scrollWidth - el.clientWidth);
}

test("the playground preview never overflows horizontally", async ({ page }) => {
await page.setViewportSize(NARROW);
await page.goto("/demo/playground.html");

// Wait for the real render path: wasm init, then the starter's chart upgrades
// into a uPlot canvas inside the preview pane.
await page.waitForSelector("#out canvas");
// Let the reflow (a ResizeObserver coalesced into a frame) settle.
await page.waitForTimeout(250);

expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);

// Shrinking the window must not reintroduce the overflow.
await page.setViewportSize({ width: 730, height: 520 });
await page.waitForTimeout(250);
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);
});
33 changes: 27 additions & 6 deletions js/packages/runtime/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class TabsElement extends AltElement {
class ChartElement extends AltElement {
#chart: UPlot | null = null;
#observer: ResizeObserver | null = null;
#frame = 0;

protected override enhance(): void {
// Lazy-loaded; a failure leaves the accessible fallback table in place.
Expand All @@ -107,23 +108,43 @@ class ChartElement extends AltElement {
* has to be pushed in explicitly.
*/
#observeWidth(chart: UPlot): void {
// Measure and observe the block-level chart container, not the host: the
// custom element is inline by default, so its clientWidth is 0. The
// container's width tracks the column, and reconciling against the chart's
// own current width corrects any build-time mismatch. The 1px tolerance
// stops sub-pixel jitter from starting a resize/scrollbar feedback loop.
const box = this.querySelector<HTMLElement>(".alt-chart-canvas") ?? this;
const sync = (): void => {
const width = box.clientWidth;
if (width > 0 && Math.abs(width - chart.width) > 1) {
chart.setSize({ width, height: CHART_HEIGHT });
}
};
sync(); // correct any build-time mismatch immediately
if (typeof ResizeObserver === "undefined") {
return;
}
let lastWidth = this.clientWidth;
// Coalesce a burst of resize callbacks (a window drag) into one redraw per
// frame; a full uPlot redraw per raw callback is what made resizing feel slow.
this.#observer = new ResizeObserver(() => {
const width = this.clientWidth;
if (width > 0 && width !== lastWidth) {
lastWidth = width;
chart.setSize({ width, height: CHART_HEIGHT });
if (this.#frame) {
return;
}
this.#frame = requestAnimationFrame(() => {
this.#frame = 0;
sync();
});
});
this.#observer.observe(this);
this.#observer.observe(box);
}

disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = null;
if (this.#frame) {
cancelAnimationFrame(this.#frame);
this.#frame = 0;
}
this.#chart?.destroy();
this.#chart = null;
}
Expand Down
17 changes: 10 additions & 7 deletions js/packages/runtime/src/graphics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,17 @@ export async function renderChart(host: HTMLElement): Promise<UPlot | null> {
ticks: { stroke: grid, width: 1 },
};

// The chart draws into a block-level container. The custom-element host is
// inline by default, so its own clientWidth is 0; the container is what has a
// real width to fit, measured before uPlot fills it.
const fallbackTable = host.querySelector("table");
const canvas = document.createElement("div");
canvas.className = "alt-chart-canvas";
host.prepend(canvas);
const width = canvas.clientWidth || host.clientWidth || 640;

const opts: import("uplot").Options = {
width: host.clientWidth || 640,
width,
height: CHART_HEIGHT,
scales: { x: { time: false } },
// A crosshair cursor that highlights the nearest series, and drag to zoom the
Expand All @@ -90,12 +99,6 @@ export async function renderChart(host: HTMLElement): Promise<UPlot | null> {
],
};

// Capture the fallback table before uPlot adds its own legend table, so we
// hide the right one.
const fallbackTable = host.querySelector("table");
const canvas = document.createElement("div");
canvas.className = "alt-chart-canvas";
host.prepend(canvas);
const chart = new uPlot(opts, aligned as never, canvas);
// Keep the data table in the DOM for accessibility, but hide it visually now
// that the chart is the primary representation.
Expand Down
Loading