@marimo-team/frontend 0.24.1-dev30 → 0.24.1-dev31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
<marimo-server-token data-token="{{ server_token }}" hidden></marimo-server-token>
|
|
67
67
|
<!-- /TODO -->
|
|
68
68
|
<title>{{ title }}</title>
|
|
69
|
-
<script type="module" crossorigin src="./assets/index-
|
|
69
|
+
<script type="module" crossorigin src="./assets/index-DemYxG9z.js"></script>
|
|
70
70
|
<link rel="modulepreload" crossorigin href="./assets/preload-helper-BPPi7vOr.js">
|
|
71
71
|
<link rel="modulepreload" crossorigin href="./assets/objects-CrbstYTV.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="./assets/chunk-C4rtOYze.js">
|
package/package.json
CHANGED
|
@@ -190,6 +190,62 @@ describe("isInAxes", () => {
|
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
// A 100x100 chart whose axes fill the whole figure.
|
|
194
|
+
function createState(): MatplotlibState {
|
|
195
|
+
return {
|
|
196
|
+
...LINEAR_AXES,
|
|
197
|
+
axesPixelBounds: [0, 0, 100, 100],
|
|
198
|
+
yBounds: [0, 10],
|
|
199
|
+
chartBase64: "first",
|
|
200
|
+
width: 100,
|
|
201
|
+
height: 100,
|
|
202
|
+
selectionColor: "blue",
|
|
203
|
+
selectionOpacity: 0.15,
|
|
204
|
+
strokeWidth: 2,
|
|
205
|
+
debounce: false,
|
|
206
|
+
value: { has_selection: false },
|
|
207
|
+
setValue: vi.fn(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// jsdom has no matchMedia, no animation frames and no canvas context.
|
|
212
|
+
function stubBrowser(ctx: Partial<CanvasRenderingContext2D> | null = null) {
|
|
213
|
+
vi.stubGlobal(
|
|
214
|
+
"matchMedia",
|
|
215
|
+
vi.fn(() => ({ addEventListener: vi.fn() })),
|
|
216
|
+
);
|
|
217
|
+
const requestAnimationFrame = vi.fn(() => 1);
|
|
218
|
+
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
|
|
219
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
220
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
|
|
221
|
+
ctx as CanvasRenderingContext2D | null,
|
|
222
|
+
);
|
|
223
|
+
return { requestAnimationFrame };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// jsdom does not fetch images; resolve them synchronously instead.
|
|
227
|
+
function stubSyncImage() {
|
|
228
|
+
vi.stubGlobal(
|
|
229
|
+
"Image",
|
|
230
|
+
class {
|
|
231
|
+
onload: (() => void) | null = null;
|
|
232
|
+
set src(_value: string) {
|
|
233
|
+
this.onload?.();
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function createRecordingContext() {
|
|
240
|
+
return {
|
|
241
|
+
setTransform: vi.fn(),
|
|
242
|
+
clearRect: vi.fn(),
|
|
243
|
+
drawImage: vi.fn(),
|
|
244
|
+
save: vi.fn(),
|
|
245
|
+
restore: vi.fn(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
193
249
|
describe("MatplotlibRenderer", () => {
|
|
194
250
|
afterEach(() => {
|
|
195
251
|
vi.unstubAllGlobals();
|
|
@@ -197,36 +253,14 @@ describe("MatplotlibRenderer", () => {
|
|
|
197
253
|
});
|
|
198
254
|
|
|
199
255
|
it("restores a new selection when the chart changes", () => {
|
|
200
|
-
|
|
201
|
-
"matchMedia",
|
|
202
|
-
vi.fn(() => ({ addEventListener: vi.fn() })),
|
|
203
|
-
);
|
|
204
|
-
vi.stubGlobal(
|
|
205
|
-
"requestAnimationFrame",
|
|
206
|
-
vi.fn(() => 1),
|
|
207
|
-
);
|
|
208
|
-
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
209
|
-
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
256
|
+
stubBrowser();
|
|
210
257
|
|
|
211
258
|
const value = {
|
|
212
259
|
type: "box",
|
|
213
260
|
has_selection: true,
|
|
214
261
|
data: { x_min: 2, x_max: 4, y_min: 2, y_max: 4 },
|
|
215
262
|
} as const;
|
|
216
|
-
const state
|
|
217
|
-
...LINEAR_AXES,
|
|
218
|
-
axesPixelBounds: [0, 0, 100, 100],
|
|
219
|
-
yBounds: [0, 10],
|
|
220
|
-
chartBase64: "first",
|
|
221
|
-
width: 100,
|
|
222
|
-
height: 100,
|
|
223
|
-
selectionColor: "blue",
|
|
224
|
-
selectionOpacity: 0.15,
|
|
225
|
-
strokeWidth: 2,
|
|
226
|
-
debounce: false,
|
|
227
|
-
value: { has_selection: false },
|
|
228
|
-
setValue: vi.fn(),
|
|
229
|
-
};
|
|
263
|
+
const state = createState();
|
|
230
264
|
const container = document.createElement("div");
|
|
231
265
|
const controller = new AbortController();
|
|
232
266
|
const renderer = new MatplotlibRenderer(container, {
|
|
@@ -252,4 +286,130 @@ describe("MatplotlibRenderer", () => {
|
|
|
252
286
|
expect(canvas.style.cursor).toBe("move");
|
|
253
287
|
controller.abort();
|
|
254
288
|
});
|
|
289
|
+
|
|
290
|
+
// Safari only fires resize on zoom, not the resolution query (#10625).
|
|
291
|
+
it("re-syncs the backing store on resize, without matchMedia firing", () => {
|
|
292
|
+
stubBrowser();
|
|
293
|
+
vi.stubGlobal("devicePixelRatio", 2);
|
|
294
|
+
|
|
295
|
+
const container = document.createElement("div");
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
new MatplotlibRenderer(container, {
|
|
298
|
+
state: createState(),
|
|
299
|
+
signal: controller.signal,
|
|
300
|
+
});
|
|
301
|
+
const canvas = container.querySelector("canvas");
|
|
302
|
+
expect(canvas?.width).toBe(200);
|
|
303
|
+
|
|
304
|
+
vi.stubGlobal("devicePixelRatio", 1);
|
|
305
|
+
window.dispatchEvent(new Event("resize"));
|
|
306
|
+
|
|
307
|
+
expect(canvas?.width).toBe(100);
|
|
308
|
+
controller.abort();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Re-sizing clears the buffer a frame before the repaint, so an over-eager
|
|
312
|
+
// guard flickers. 700 * 1.1 is the case a truncating comparison misses.
|
|
313
|
+
it("leaves the backing store alone when resize fires at an unchanged DPR", () => {
|
|
314
|
+
const { requestAnimationFrame } = stubBrowser();
|
|
315
|
+
vi.stubGlobal("devicePixelRatio", 1.1);
|
|
316
|
+
|
|
317
|
+
const container = document.createElement("div");
|
|
318
|
+
const controller = new AbortController();
|
|
319
|
+
new MatplotlibRenderer(container, {
|
|
320
|
+
state: { ...createState(), width: 700 },
|
|
321
|
+
signal: controller.signal,
|
|
322
|
+
});
|
|
323
|
+
const canvas = container.querySelector("canvas");
|
|
324
|
+
expect(canvas?.width).toBe(770);
|
|
325
|
+
|
|
326
|
+
requestAnimationFrame.mockClear();
|
|
327
|
+
window.dispatchEvent(new Event("resize"));
|
|
328
|
+
|
|
329
|
+
expect(canvas?.width).toBe(770);
|
|
330
|
+
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
|
331
|
+
controller.abort();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("draws with the backing-store scale, not the current devicePixelRatio", () => {
|
|
335
|
+
const ctx = createRecordingContext();
|
|
336
|
+
stubBrowser(ctx);
|
|
337
|
+
stubSyncImage();
|
|
338
|
+
vi.stubGlobal("devicePixelRatio", 2);
|
|
339
|
+
|
|
340
|
+
const state = createState();
|
|
341
|
+
const container = document.createElement("div");
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
const renderer = new MatplotlibRenderer(container, {
|
|
344
|
+
state,
|
|
345
|
+
signal: controller.signal,
|
|
346
|
+
});
|
|
347
|
+
expect(container.querySelector("canvas")?.width).toBe(200);
|
|
348
|
+
|
|
349
|
+
// Zoom with nothing re-syncing the buffer: it still holds the old DPR, so
|
|
350
|
+
// the redraw must too, or the image lands scaled by dpr_new / dpr_old.
|
|
351
|
+
vi.stubGlobal("devicePixelRatio", 1);
|
|
352
|
+
ctx.setTransform.mockClear();
|
|
353
|
+
renderer.update({ ...state, strokeWidth: 4 });
|
|
354
|
+
|
|
355
|
+
expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]);
|
|
356
|
+
expect(ctx.drawImage).toHaveBeenLastCalledWith(
|
|
357
|
+
expect.anything(),
|
|
358
|
+
0,
|
|
359
|
+
0,
|
|
360
|
+
100,
|
|
361
|
+
100,
|
|
362
|
+
);
|
|
363
|
+
controller.abort();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("clears the whole backing store when a re-run swaps the chart", () => {
|
|
367
|
+
const ctx = createRecordingContext();
|
|
368
|
+
stubBrowser(ctx);
|
|
369
|
+
vi.stubGlobal("devicePixelRatio", 2);
|
|
370
|
+
|
|
371
|
+
const state = createState();
|
|
372
|
+
const container = document.createElement("div");
|
|
373
|
+
const controller = new AbortController();
|
|
374
|
+
const renderer = new MatplotlibRenderer(container, {
|
|
375
|
+
state,
|
|
376
|
+
signal: controller.signal,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
vi.stubGlobal("devicePixelRatio", 1);
|
|
380
|
+
ctx.setTransform.mockClear();
|
|
381
|
+
ctx.clearRect.mockClear();
|
|
382
|
+
renderer.update({ ...state, chartBase64: "second" });
|
|
383
|
+
|
|
384
|
+
// A stale scale here would leave the outer 3/4 of the buffer unwiped.
|
|
385
|
+
expect(ctx.setTransform.mock.calls).toEqual([[2, 0, 0, 2, 0, 0]]);
|
|
386
|
+
expect(ctx.clearRect.mock.calls).toEqual([[0, 0, 100, 100]]);
|
|
387
|
+
controller.abort();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("scales each axis by its own backing-store ratio", () => {
|
|
391
|
+
const ctx = createRecordingContext();
|
|
392
|
+
stubBrowser(ctx);
|
|
393
|
+
stubSyncImage();
|
|
394
|
+
// canvas.width/height are integers, so this truncates the two dimensions
|
|
395
|
+
// by different fractions: 150 * 1.25 -> 187 (0.5 lost), 500 * 1.25 -> 625
|
|
396
|
+
// (exact). One shared, width-derived scale would under-cover the buffer
|
|
397
|
+
// vertically and leave its bottom rows unwiped.
|
|
398
|
+
vi.stubGlobal("devicePixelRatio", 1.25);
|
|
399
|
+
|
|
400
|
+
const state = { ...createState(), width: 150, height: 500 };
|
|
401
|
+
const container = document.createElement("div");
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
new MatplotlibRenderer(container, { state, signal: controller.signal });
|
|
404
|
+
|
|
405
|
+
const canvas = container.querySelector("canvas");
|
|
406
|
+
expect(canvas?.width).toBe(187);
|
|
407
|
+
expect(canvas?.height).toBe(625);
|
|
408
|
+
// The pre-load clear and the draw each set up their own transform.
|
|
409
|
+
const transform = [187 / 150, 0, 0, 625 / 500, 0, 0];
|
|
410
|
+
const rect = [0, 0, 150, 500];
|
|
411
|
+
expect(ctx.setTransform.mock.calls).toEqual([transform, transform]);
|
|
412
|
+
expect(ctx.clearRect.mock.calls).toEqual([rect, rect]);
|
|
413
|
+
controller.abort();
|
|
414
|
+
});
|
|
255
415
|
});
|
|
@@ -281,6 +281,8 @@ export class MatplotlibRenderer {
|
|
|
281
281
|
#image: HTMLImageElement | null = null;
|
|
282
282
|
#imageGeneration = 0;
|
|
283
283
|
#currentChartBase64 = "";
|
|
284
|
+
/** The devicePixelRatio the canvas buffer was last sized with. */
|
|
285
|
+
#backingDpr = 1;
|
|
284
286
|
|
|
285
287
|
constructor(
|
|
286
288
|
container: HTMLDivElement,
|
|
@@ -317,8 +319,12 @@ export class MatplotlibRenderer {
|
|
|
317
319
|
});
|
|
318
320
|
|
|
319
321
|
// Watch for devicePixelRatio changes (e.g. browser zoom, moving between
|
|
320
|
-
// displays). matchMedia fires exactly once per DPR transition
|
|
322
|
+
// displays). matchMedia fires exactly once per DPR transition, but Safari
|
|
323
|
+
// doesn't re-evaluate the query on zoom — it only fires resize (#10625).
|
|
321
324
|
this.#watchDevicePixelRatio(options.signal);
|
|
325
|
+
window.addEventListener("resize", this.#syncForDevicePixelRatio, {
|
|
326
|
+
signal: options.signal,
|
|
327
|
+
});
|
|
322
328
|
|
|
323
329
|
// Clean up on abort
|
|
324
330
|
options.signal.addEventListener("abort", () => {
|
|
@@ -334,6 +340,7 @@ export class MatplotlibRenderer {
|
|
|
334
340
|
/** Set the canvas buffer + CSS size to match current logical size and DPR. */
|
|
335
341
|
#syncCanvasSize(canvas: HTMLCanvasElement = this.#canvas): void {
|
|
336
342
|
const dpr = globalThis.devicePixelRatio ?? 1;
|
|
343
|
+
this.#backingDpr = dpr;
|
|
337
344
|
const { width, height } = this.#state;
|
|
338
345
|
canvas.width = width * dpr;
|
|
339
346
|
canvas.height = height * dpr;
|
|
@@ -343,6 +350,33 @@ export class MatplotlibRenderer {
|
|
|
343
350
|
canvas.style.aspectRatio = `${width} / ${height}`;
|
|
344
351
|
}
|
|
345
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Scale to draw at, read back from the buffer rather than from
|
|
355
|
+
* `devicePixelRatio`, so sizing and drawing cannot disagree (#10625).
|
|
356
|
+
* Per-axis: `canvas.width`/`height` are integers, so a fractional DPR
|
|
357
|
+
* truncates each dimension by a different fraction of a device pixel.
|
|
358
|
+
*/
|
|
359
|
+
get #backingScale(): { x: number; y: number } {
|
|
360
|
+
const { width, height } = this.#state;
|
|
361
|
+
const dpr = globalThis.devicePixelRatio ?? 1;
|
|
362
|
+
return {
|
|
363
|
+
x: width > 0 ? this.#canvas.width / width : dpr,
|
|
364
|
+
y: height > 0 ? this.#canvas.height / height : dpr,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Re-size the buffer when the DPR changed. resize fires far more often than
|
|
370
|
+
* the DPR moves, and `canvas.width` truncates, so compare ratios directly.
|
|
371
|
+
*/
|
|
372
|
+
#syncForDevicePixelRatio = (): void => {
|
|
373
|
+
if ((globalThis.devicePixelRatio ?? 1) === this.#backingDpr) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.#syncCanvasSize();
|
|
377
|
+
this.#scheduleRedraw();
|
|
378
|
+
};
|
|
379
|
+
|
|
346
380
|
/**
|
|
347
381
|
* Observe devicePixelRatio changes via matchMedia. Each listener fires once
|
|
348
382
|
* per transition, so we re-register after every change.
|
|
@@ -355,8 +389,7 @@ export class MatplotlibRenderer {
|
|
|
355
389
|
`(resolution: ${globalThis.devicePixelRatio ?? 1}dppx)`,
|
|
356
390
|
);
|
|
357
391
|
const onChange = () => {
|
|
358
|
-
this.#
|
|
359
|
-
this.#drawCanvas();
|
|
392
|
+
this.#syncForDevicePixelRatio();
|
|
360
393
|
// Re-register for the next DPR transition
|
|
361
394
|
this.#watchDevicePixelRatio(signal);
|
|
362
395
|
};
|
|
@@ -416,8 +449,8 @@ export class MatplotlibRenderer {
|
|
|
416
449
|
this.#image = null;
|
|
417
450
|
const ctx = this.#canvas.getContext("2d");
|
|
418
451
|
if (ctx) {
|
|
419
|
-
const
|
|
420
|
-
ctx.setTransform(
|
|
452
|
+
const scale = this.#backingScale;
|
|
453
|
+
ctx.setTransform(scale.x, 0, 0, scale.y, 0, 0);
|
|
421
454
|
ctx.clearRect(0, 0, this.#state.width, this.#state.height);
|
|
422
455
|
}
|
|
423
456
|
|
|
@@ -447,8 +480,8 @@ export class MatplotlibRenderer {
|
|
|
447
480
|
const ix = this.#interaction;
|
|
448
481
|
|
|
449
482
|
// Scale for HiDPI: all coordinates remain in logical pixels
|
|
450
|
-
const
|
|
451
|
-
ctx.setTransform(
|
|
483
|
+
const scale = this.#backingScale;
|
|
484
|
+
ctx.setTransform(scale.x, 0, 0, scale.y, 0, 0);
|
|
452
485
|
|
|
453
486
|
// Clear and draw the base image
|
|
454
487
|
ctx.clearRect(0, 0, s.width, s.height);
|