From 5f8b799a1953648d2715df3cf7cc2e017ea088e2 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Mon, 6 Oct 2025 13:47:33 +0100 Subject: [PATCH 01/14] test(e2e): add Playwright setup and notebook load spec --- .gitignore | 4 ++ package-lock.json | 64 +++++++++++++++++++++++++++++ package.json | 7 +++- playwright.config.ts | 37 +++++++++++++++++ tests/e2e/README.md | 30 ++++++++++++++ tests/e2e/fixtures/.gitignore | 1 + tests/e2e/fixtures/simple-map.ipynb | 63 ++++++++++++++++++++++++++++ tests/e2e/notebook-load.spec.ts | 14 +++++++ 8 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/fixtures/.gitignore create mode 100644 tests/e2e/fixtures/simple-map.ipynb create mode 100644 tests/e2e/notebook-load.spec.ts diff --git a/.gitignore b/.gitignore index 92c4df4d..c2f96860 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ node_modules .env .env.local .env.*.local + +# Playwright test artifacts +test-results/ +playwright-report/ diff --git a/package-lock.json b/package-lock.json index d86d2007..b16fab05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@anywidget/types": "^0.2.0", "@eslint/js": "^9.36.0", "@jupyter-widgets/base": "^6.0.10", + "@playwright/test": "^1.55.1", "@statelyai/inspect": "^0.4.0", "@types/lodash": "^4.17.13", "@types/lodash.debounce": "^4.0.9", @@ -4818,6 +4819,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@probe.gl/env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", @@ -11519,6 +11536,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 82f5c2cf..dd3b7ca5 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ }, "dependencies": { "@anywidget/react": "^0.2.0", + "@babel/runtime": "^7.28.4", "@deck.gl/core": "^9.1.14", "@deck.gl/extensions": "^9.1.14", "@deck.gl/layers": "^9.1.14", "@deck.gl/react": "^9.1.14", "@geoarrow/deck.gl-layers": "^0.3.1", - "@babel/runtime": "^7.28.4", "@nextui-org/react": "^2.4.8", "@xstate/react": "^6.0.0", "apache-arrow": "^21.0.0", @@ -31,6 +31,7 @@ "@anywidget/types": "^0.2.0", "@eslint/js": "^9.36.0", "@jupyter-widgets/base": "^6.0.10", + "@playwright/test": "^1.55.1", "@statelyai/inspect": "^0.4.0", "@types/lodash": "^4.17.13", "@types/lodash.debounce": "^4.0.9", @@ -63,7 +64,9 @@ "prettier:check": "prettier './src/**/*.{ts,tsx,css}' --check", "prettier": "prettier './src/**/*.{ts,tsx,css}' --write", "lint": "eslint src", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "volta": { "node": "18.18.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..68cd0269 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + timeout: 60000, + expect: { + timeout: 30000, + }, + reporter: 'list', + + use: { + baseURL: 'http://localhost:8889', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + navigationTimeout: 30000, + browserName: 'chromium', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=""', + url: 'http://localhost:8889', + reuseExistingServer: false, // Always restart for clean state + timeout: 30000, + }, +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..f7a5acaf --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,30 @@ +# End-to-End Tests + +Playwright-based end-to-end tests for Lonboard widgets in JupyterLab. + +## Running Tests + +```bash +# Run all e2e tests +npm run test:e2e + +# Run with UI mode +npm run test:e2e:ui +``` + +## Architecture + +- **JupyterLab**: Runs on port 8889 (isolated from dev instances on 8888) +- **Working Directory**: `tests/e2e/fixtures/` (only test notebooks visible) +- **Clean State**: JupyterLab server restarts for each test run (`reuseExistingServer: false`) + - Fresh kernel state on every run + - No session persistence between test runs + - No interference with development sessions + +## Test Fixtures + +Test notebooks are stored in `tests/e2e/fixtures/` and committed to the repository. They provide scaffolding to replicate correct user workflows. + +### simple-map.ipynb + +Basic test notebook with 4 points in a grid displaying a simple scatterplot map. diff --git a/tests/e2e/fixtures/.gitignore b/tests/e2e/fixtures/.gitignore new file mode 100644 index 00000000..87620ac7 --- /dev/null +++ b/tests/e2e/fixtures/.gitignore @@ -0,0 +1 @@ +.ipynb_checkpoints/ diff --git a/tests/e2e/fixtures/simple-map.ipynb b/tests/e2e/fixtures/simple-map.ipynb new file mode 100644 index 00000000..c3c43576 --- /dev/null +++ b/tests/e2e/fixtures/simple-map.ipynb @@ -0,0 +1,63 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import geopandas as gpd\n", + "from shapely.geometry import Point\n", + "from lonboard import Map, ScatterplotLayer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simple test data - 4 points in a grid\n", + "points = [\n", + " Point(-10, -10),\n", + " Point(-10, 10),\n", + " Point(10, -10),\n", + " Point(10, 10),\n", + "]\n", + "gdf = gpd.GeoDataFrame(geometry=points, crs='EPSG:4326')\n", + "\n", + "layer = ScatterplotLayer.from_geopandas(\n", + " gdf,\n", + " get_fill_color=[255, 0, 0],\n", + " get_radius=100000,\n", + ")\n", + "\n", + "m = Map(layer, view_state={\"longitude\": 0, \"latitude\": 0, \"zoom\": 2})\n", + "m" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check selected bounds (run after bbox selection)\n", + "print(f\"Selected bounds: {m.selected_bounds}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/e2e/notebook-load.spec.ts b/tests/e2e/notebook-load.spec.ts new file mode 100644 index 00000000..48c1ebf7 --- /dev/null +++ b/tests/e2e/notebook-load.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Notebook Load', () => { + test('JupyterLab starts and loads notebook', async ({ page }) => { + // Open a simple map notebook + await page.goto('/lab/tree/simple-map.ipynb'); + + // Verify the correct notebook tab is active + await expect(page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")')).toBeVisible({ timeout: 10000 }); + + // Verify kernel status shows in footer + await expect(page.locator('text=/Python 3.*Idle/')).toBeVisible({ timeout: 30000 }); + }); +}); From fbac77889b6a8c5840b0978685cd0d82fb93582f Mon Sep 17 00:00:00 2001 From: Vitor George Date: Mon, 6 Oct 2025 18:12:12 +0100 Subject: [PATCH 02/14] Add Playwright e2e tests with DeckGL canvas interaction support - Set up Playwright with isolated JupyterLab instance (port 8889) - Expose DeckGL instance via React ref for programmatic access - Create test helpers for DeckGL canvas interactions (deckClick, deckHover, deckDrag) - Create notebook helpers for JupyterLab operations - Implement bbox selection test that verifies selected_bounds syncs to Python - Add test scripts: test:e2e, test:e2e:ui, jupyter:test DeckGL canvas interactions bypass DOM events and call handlers directly via deck.pickObject() and deck.props.onClick/onHover, enabling automated testing of map interactions. --- package.json | 3 +- src/index.tsx | 8 ++ tests/e2e/README.md | 32 +++--- tests/e2e/bbox-select.spec.ts | 45 +++++++++ tests/e2e/fixtures/simple-map.ipynb | 26 ++++- tests/e2e/helpers/deck-interaction.ts | 135 ++++++++++++++++++++++++++ tests/e2e/helpers/notebook.ts | 41 ++++++++ tests/e2e/notebook-load.spec.ts | 7 +- 8 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/bbox-select.spec.ts create mode 100644 tests/e2e/helpers/deck-interaction.ts create mode 100644 tests/e2e/helpers/notebook.ts diff --git a/package.json b/package.json index dd3b7ca5..70a08139 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "lint": "eslint src", "test": "vitest run", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "jupyter:test": "uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=''" }, "volta": { "node": "18.18.2", diff --git a/src/index.tsx b/src/index.tsx index 5d5a0f0b..dbb697d8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -94,6 +94,13 @@ function App() { const [justClicked, setJustClicked] = useState(false); + const deckRef = React.useRef(null); + useEffect(() => { + if (deckRef.current && typeof window !== "undefined") { + (window as any).__deck = deckRef.current.deck; + } + }, [deckRef.current]); + const model = useModel(); const [mapStyle] = useModelState("basemap_style"); @@ -238,6 +245,7 @@ function App() { )}
diff --git a/tests/e2e/README.md b/tests/e2e/README.md index f7a5acaf..c825a32b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,30 +1,30 @@ # End-to-End Tests -Playwright-based end-to-end tests for Lonboard widgets in JupyterLab. +Playwright tests for Lonboard widgets in JupyterLab. ## Running Tests ```bash -# Run all e2e tests -npm run test:e2e - -# Run with UI mode -npm run test:e2e:ui +npm run test:e2e # Run all tests +npm run test:e2e:ui # Run with UI mode +npm run jupyter:test # Start test JupyterLab manually (port 8889) ``` ## Architecture -- **JupyterLab**: Runs on port 8889 (isolated from dev instances on 8888) -- **Working Directory**: `tests/e2e/fixtures/` (only test notebooks visible) -- **Clean State**: JupyterLab server restarts for each test run (`reuseExistingServer: false`) - - Fresh kernel state on every run - - No session persistence between test runs - - No interference with development sessions +- Tests run on port 8889 (isolated from dev on 8888) +- Fresh JupyterLab server per test run +- Fixtures in `tests/e2e/fixtures/` + +## DeckGL Canvas Interactions -## Test Fixtures +Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deck-interaction.ts`: -Test notebooks are stored in `tests/e2e/fixtures/` and committed to the repository. They provide scaffolding to replicate correct user workflows. +```typescript +import { deckClick, deckHover } from "./helpers/deck-interaction"; -### simple-map.ipynb +await deckClick(page, x, y); // Calls deck.props.onClick() +await deckHover(page, x, y); // Calls deck.props.onHover() +``` -Basic test notebook with 4 points in a grid displaying a simple scatterplot map. +See `bbox-select.spec.ts` for example usage. diff --git a/tests/e2e/bbox-select.spec.ts b/tests/e2e/bbox-select.spec.ts new file mode 100644 index 00000000..51ac8ba8 --- /dev/null +++ b/tests/e2e/bbox-select.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { deckClick, deckHover } from "./helpers/deck-interaction"; +import { openNotebook, runCells, waitForMapReady } from "./helpers/notebook"; + +test.describe("BBox selection", () => { + test("draws bbox and syncs selected_bounds to Python", async ({ page }) => { + const notebook = await openNotebook(page, "simple-map.ipynb"); + await runCells(notebook, 0, 2); + await waitForMapReady(page); + await page.waitForTimeout(2000); + + const bboxButton = page.getByRole("button", { name: "Select BBox" }); + await expect(bboxButton).toBeVisible({ timeout: 10000 }); + await bboxButton.click(); + + const cancelButton = page.getByRole("button", { name: "Cancel drawing" }); + await expect(cancelButton).toBeVisible({ timeout: 5000 }); + + const deckCanvas = page.locator('canvas#deckgl-overlay').first(); + const canvasBox = await deckCanvas.boundingBox(); + if (!canvasBox) throw new Error("Canvas not found"); + + const startX = canvasBox.x + 200; + const startY = canvasBox.y + 200; + const endX = canvasBox.x + 400; + const endY = canvasBox.y + 400; + + await deckClick(page, startX, startY); + await page.waitForTimeout(300); + await deckHover(page, endX, endY); + await page.waitForTimeout(300); + await deckClick(page, endX, endY); + await page.waitForTimeout(500); + + const clearButton = page.getByRole("button", { name: "Clear bounding box" }); + await expect(clearButton).toBeVisible({ timeout: 2000 }); + + await notebook.locator(".jp-Cell").nth(2).click(); + await page.keyboard.press("Shift+Enter"); + + const output = page.locator(".jp-OutputArea-output").last(); + await expect(output).toBeVisible({ timeout: 5000 }); + await expect(output).toContainText(/Selected bounds: \([-\d\., ]+\)/); + }); +}); diff --git a/tests/e2e/fixtures/simple-map.ipynb b/tests/e2e/fixtures/simple-map.ipynb index c3c43576..09a9e580 100644 --- a/tests/e2e/fixtures/simple-map.ipynb +++ b/tests/e2e/fixtures/simple-map.ipynb @@ -45,17 +45,39 @@ "# Check selected bounds (run after bbox selection)\n", "print(f\"Selected bounds: {m.selected_bounds}\")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.10.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" } }, "nbformat": 4, diff --git a/tests/e2e/helpers/deck-interaction.ts b/tests/e2e/helpers/deck-interaction.ts new file mode 100644 index 00000000..1a0a2ea1 --- /dev/null +++ b/tests/e2e/helpers/deck-interaction.ts @@ -0,0 +1,135 @@ +import type { Page } from "@playwright/test"; +export async function deckClick(page: Page, x: number, y: number) { + await page.evaluate(([x, y]) => { + const deck = (window as any).__deck; + if (!deck) { + throw new Error('No deck instance on window'); + } + + let info; + if (typeof deck.pickObject === 'function') { + info = deck.pickObject({ x, y, radius: 2 }); + } + + if (!info) { + info = { + x, + y, + object: null, + layer: null, + index: -1, + coordinate: [0, 0], + pixel: [x, y] + }; + } + + const srcEvent = new PointerEvent('click', { + clientX: x, + clientY: y, + buttons: 1, + pointerType: 'mouse', + bubbles: true + }); + const evt = { + type: 'click', + srcEvent, + center: [x, y], + offsetCenter: { x, y } + }; + + if (deck.props && deck.props.onClick) { + deck.props.onClick(info, evt); + } + }, [x, y]); +} + +export async function deckHover(page: Page, x: number, y: number) { + await page.evaluate(([x, y]) => { + const deck = (window as any).__deck; + if (!deck) { + throw new Error('No deck instance on window'); + } + + const info = deck.pickObject({ x, y, radius: 2 }) || { + x, + y, + object: null, + layer: null, + index: -1, + coordinate: undefined + }; + + const srcEvent = new PointerEvent('pointermove', { + clientX: x, + clientY: y, + buttons: 0, + pointerType: 'mouse', + bubbles: true + }); + const evt = { + type: 'pointermove', + srcEvent, + center: [x, y], + offsetCenter: { x, y } + }; + + if (deck.props.onHover) { + deck.props.onHover(info, evt); + } + }, [x, y]); +} + +export async function deckDrag( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, + steps: number = 5 +) { + await page.evaluate(([start, end, steps]) => { + const deck = (window as any).__deck; + if (!deck) { + throw new Error('No deck instance on window'); + } + + const makeEvent = (type: string, x: number, y: number, buttons: number) => { + const srcEvent = new PointerEvent(type, { + clientX: x, + clientY: y, + buttons, + pointerType: 'mouse', + bubbles: true + }); + return { + type, + srcEvent, + center: [x, y], + offsetCenter: { x, y } + }; + }; + + if (deck.props.onDragStart) { + deck.props.onDragStart(makeEvent('pointerdown', start.x, start.y, 1)); + } + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = start.x + (end.x - start.x) * t; + const y = start.y + (end.y - start.y) * t; + + if (deck.props.onDrag) { + deck.props.onDrag(makeEvent('pointermove', x, y, 1)); + } + } + + if (deck.props.onDragEnd) { + deck.props.onDragEnd(makeEvent('pointerup', end.x, end.y, 0)); + } + }, [start, end, steps]); +} + +export async function waitForDeck(page: Page, timeout: number = 10000): Promise { + await page.waitForFunction( + () => typeof (window as any).__deck !== 'undefined', + { timeout } + ); +} diff --git a/tests/e2e/helpers/notebook.ts b/tests/e2e/helpers/notebook.ts new file mode 100644 index 00000000..a631c3bc --- /dev/null +++ b/tests/e2e/helpers/notebook.ts @@ -0,0 +1,41 @@ +import { expect, type Page, type Locator } from "@playwright/test"; +import { waitForDeck } from "./deck-interaction"; +export async function openNotebook(page: Page, notebookPath: string): Promise { + await page.goto(`/lab/tree/${notebookPath}`); + const notebook = page.locator(".jp-Notebook"); + await expect(notebook).toBeVisible({ timeout: 30000 }); + return notebook; +} + +export async function runCell(notebook: Locator, cellIndex: number): Promise { + await notebook.locator(".jp-Cell").nth(cellIndex).click(); + await notebook.page().keyboard.press("Shift+Enter"); +} + +export async function runCells(notebook: Locator, startIndex: number, count: number): Promise { + await notebook.locator(".jp-Cell").nth(startIndex).click(); + for (let i = 0; i < count; i++) { + await notebook.page().keyboard.press("Shift+Enter"); + } +} + +export async function waitForMapReady(page: Page): Promise { + const mapRoot = page.locator("[data-jp-suppress-context-menu]"); + await expect(mapRoot.first()).toBeVisible({ timeout: 30000 }); + + const deckCanvas = page.locator('[id^="map-"] canvas#deckgl-overlay').first(); + await expect(deckCanvas).toBeVisible({ timeout: 30000 }); + + await expect + .poll(async () => { + const box = await deckCanvas.boundingBox(); + return box && box.width > 0 && box.height > 0; + }, { timeout: 10000 }) + .toBe(true); + + await waitForDeck(page); +} + +export function getCellOutput(notebook: Locator, cellIndex: number): Locator { + return notebook.locator(".jp-Cell").nth(cellIndex).locator(".jp-OutputArea-output").last(); +} diff --git a/tests/e2e/notebook-load.spec.ts b/tests/e2e/notebook-load.spec.ts index 48c1ebf7..aed42fca 100644 --- a/tests/e2e/notebook-load.spec.ts +++ b/tests/e2e/notebook-load.spec.ts @@ -1,14 +1,11 @@ import { test, expect } from '@playwright/test'; +import { openNotebook } from './helpers/notebook'; test.describe('Notebook Load', () => { test('JupyterLab starts and loads notebook', async ({ page }) => { - // Open a simple map notebook - await page.goto('/lab/tree/simple-map.ipynb'); + await openNotebook(page, 'simple-map.ipynb'); - // Verify the correct notebook tab is active await expect(page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")')).toBeVisible({ timeout: 10000 }); - - // Verify kernel status shows in footer await expect(page.locator('text=/Python 3.*Idle/')).toBeVisible({ timeout: 30000 }); }); }); From dbfb8556579a107251ca75ddcf81358a4d1fde1f Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 10:18:09 +0100 Subject: [PATCH 03/14] test(e2e): refactor DeckGL helpers and fix optional unproject --- tests/e2e/README.md | 13 +- tests/e2e/bbox-select.spec.ts | 67 +++++--- tests/e2e/helpers/assertions.ts | 45 +++++ tests/e2e/helpers/deck-interaction.ts | 135 --------------- tests/e2e/helpers/deckgl/index.ts | 6 + tests/e2e/helpers/deckgl/interactions.ts | 207 +++++++++++++++++++++++ tests/e2e/helpers/deckgl/types.ts | 78 +++++++++ tests/e2e/helpers/notebook.ts | 157 ++++++++++++++++- 8 files changed, 535 insertions(+), 173 deletions(-) create mode 100644 tests/e2e/helpers/assertions.ts delete mode 100644 tests/e2e/helpers/deck-interaction.ts create mode 100644 tests/e2e/helpers/deckgl/index.ts create mode 100644 tests/e2e/helpers/deckgl/interactions.ts create mode 100644 tests/e2e/helpers/deckgl/types.ts diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c825a32b..68d9d932 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -18,13 +18,16 @@ npm run jupyter:test # Start test JupyterLab manually (port 8889) ## DeckGL Canvas Interactions -Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deck-interaction.ts`: +Playwright mouse events don't trigger DeckGL handlers. Use helpers from `helpers/deckgl/`: ```typescript -import { deckClick, deckHover } from "./helpers/deck-interaction"; +import { deckPointerEvent } from "./helpers/deckgl"; -await deckClick(page, x, y); // Calls deck.props.onClick() -await deckHover(page, x, y); // Calls deck.props.onHover() +// Use canvas-relative coordinates (pixels from canvas top-left corner) +await deckPointerEvent(page, "click", 200, 300); +await deckPointerEvent(page, "hover", 400, 500); ``` -See `bbox-select.spec.ts` for example usage. +The helpers automatically convert pixel coordinates to geographic coordinates and invoke DeckGL event handlers. See JSDoc comments in `helpers/deckgl/interactions.ts` for implementation details. + +Example: `bbox-select.spec.ts` diff --git a/tests/e2e/bbox-select.spec.ts b/tests/e2e/bbox-select.spec.ts index 51ac8ba8..fb3a44c7 100644 --- a/tests/e2e/bbox-select.spec.ts +++ b/tests/e2e/bbox-select.spec.ts @@ -1,6 +1,33 @@ -import { test, expect } from "@playwright/test"; -import { deckClick, deckHover } from "./helpers/deck-interaction"; -import { openNotebook, runCells, waitForMapReady } from "./helpers/notebook"; +import { test, expect, Page } from "@playwright/test"; +import { deckPointerEvent } from "./helpers/deckgl"; +import { + openNotebook, + runCells, + waitForMapReady, + executeCellAndWaitForOutput, +} from "./helpers/notebook"; +import { validateBounds } from "./helpers/assertions"; + +/** + * Draws a bounding box on the DeckGL canvas by clicking start and end positions. + */ +async function drawBbox( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, +) { + // Click to set bbox start position + await deckPointerEvent(page, "click", start.x, start.y); + await page.waitForTimeout(300); + + // Hover to preview bbox size + await deckPointerEvent(page, "hover", end.x, end.y); + await page.waitForTimeout(300); + + // Click to set bbox end position and complete drawing + await deckPointerEvent(page, "click", end.x, end.y); + await page.waitForTimeout(500); +} test.describe("BBox selection", () => { test("draws bbox and syncs selected_bounds to Python", async ({ page }) => { @@ -9,37 +36,29 @@ test.describe("BBox selection", () => { await waitForMapReady(page); await page.waitForTimeout(2000); + // Start bbox selection mode const bboxButton = page.getByRole("button", { name: "Select BBox" }); await expect(bboxButton).toBeVisible({ timeout: 10000 }); await bboxButton.click(); + // Verify drawing mode is active const cancelButton = page.getByRole("button", { name: "Cancel drawing" }); await expect(cancelButton).toBeVisible({ timeout: 5000 }); - const deckCanvas = page.locator('canvas#deckgl-overlay').first(); - const canvasBox = await deckCanvas.boundingBox(); - if (!canvasBox) throw new Error("Canvas not found"); - - const startX = canvasBox.x + 200; - const startY = canvasBox.y + 200; - const endX = canvasBox.x + 400; - const endY = canvasBox.y + 400; - - await deckClick(page, startX, startY); - await page.waitForTimeout(300); - await deckHover(page, endX, endY); - await page.waitForTimeout(300); - await deckClick(page, endX, endY); - await page.waitForTimeout(500); + // Draw bbox using canvas-relative coordinates (pixels from canvas top-left) + await drawBbox(page, { x: 200, y: 200 }, { x: 400, y: 400 }); - const clearButton = page.getByRole("button", { name: "Clear bounding box" }); + // Verify bbox was drawn + const clearButton = page.getByRole("button", { + name: "Clear bounding box", + }); await expect(clearButton).toBeVisible({ timeout: 2000 }); - await notebook.locator(".jp-Cell").nth(2).click(); - await page.keyboard.press("Shift+Enter"); + // Execute cell to check selected bounds + const output = await executeCellAndWaitForOutput(notebook, 2); - const output = page.locator(".jp-OutputArea-output").last(); - await expect(output).toBeVisible({ timeout: 5000 }); - await expect(output).toContainText(/Selected bounds: \([-\d\., ]+\)/); + // Verify bounds are valid geographic coordinates + const outputText = await output.textContent(); + validateBounds(outputText); }); }); diff --git a/tests/e2e/helpers/assertions.ts b/tests/e2e/helpers/assertions.ts new file mode 100644 index 00000000..c5eb8a05 --- /dev/null +++ b/tests/e2e/helpers/assertions.ts @@ -0,0 +1,45 @@ +import { expect } from "@playwright/test"; + +/** + * Validates that bounds text contains valid geographic coordinates. + * Expects format: "Selected bounds: (minLon, minLat, maxLon, maxLat)" + * + * @param boundsText - Text output containing bounds coordinates + * @throws AssertionError if bounds are invalid + */ +export function validateBounds(boundsText: string | null) { + expect(boundsText, "Expected output to contain coordinates").not.toContain( + "None", + ); + expect(boundsText, "Expected output to not be zeros").not.toContain( + "(0, 0, 0, 0)", + ); + + // Extract and validate the actual bounds values (handles scientific notation and decimals) + const boundsMatch = boundsText?.match( + /Selected bounds: \(([-\deE.]+), ([-\deE.]+), ([-\deE.]+), ([-\deE.]+)\)/, + ); + expect( + boundsMatch, + `Expected to find bounds pattern in: ${boundsText}`, + ).toBeTruthy(); + if (boundsMatch) { + const [, minLon, minLat, maxLon, maxLat] = boundsMatch.map(Number); + + // Verify we have valid coordinate ranges + expect(minLon, "minLon should not be zero").not.toBe(0); + expect(minLat, "minLat should not be zero").not.toBe(0); + expect(maxLon, "maxLon should not be zero").not.toBe(0); + expect(maxLat, "maxLat should not be zero").not.toBe(0); + + // Verify min < max for both dimensions + expect(minLon, "minLon should be less than maxLon").toBeLessThan(maxLon); + expect(minLat, "minLat should be less than maxLat").toBeLessThan(maxLat); + + // Verify coordinates are within valid lat/lon ranges + expect(minLon, "minLon should be >= -180").toBeGreaterThanOrEqual(-180); + expect(maxLon, "maxLon should be <= 180").toBeLessThanOrEqual(180); + expect(minLat, "minLat should be >= -90").toBeGreaterThanOrEqual(-90); + expect(maxLat, "maxLat should be <= 90").toBeLessThanOrEqual(90); + } +} diff --git a/tests/e2e/helpers/deck-interaction.ts b/tests/e2e/helpers/deck-interaction.ts deleted file mode 100644 index 1a0a2ea1..00000000 --- a/tests/e2e/helpers/deck-interaction.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Page } from "@playwright/test"; -export async function deckClick(page: Page, x: number, y: number) { - await page.evaluate(([x, y]) => { - const deck = (window as any).__deck; - if (!deck) { - throw new Error('No deck instance on window'); - } - - let info; - if (typeof deck.pickObject === 'function') { - info = deck.pickObject({ x, y, radius: 2 }); - } - - if (!info) { - info = { - x, - y, - object: null, - layer: null, - index: -1, - coordinate: [0, 0], - pixel: [x, y] - }; - } - - const srcEvent = new PointerEvent('click', { - clientX: x, - clientY: y, - buttons: 1, - pointerType: 'mouse', - bubbles: true - }); - const evt = { - type: 'click', - srcEvent, - center: [x, y], - offsetCenter: { x, y } - }; - - if (deck.props && deck.props.onClick) { - deck.props.onClick(info, evt); - } - }, [x, y]); -} - -export async function deckHover(page: Page, x: number, y: number) { - await page.evaluate(([x, y]) => { - const deck = (window as any).__deck; - if (!deck) { - throw new Error('No deck instance on window'); - } - - const info = deck.pickObject({ x, y, radius: 2 }) || { - x, - y, - object: null, - layer: null, - index: -1, - coordinate: undefined - }; - - const srcEvent = new PointerEvent('pointermove', { - clientX: x, - clientY: y, - buttons: 0, - pointerType: 'mouse', - bubbles: true - }); - const evt = { - type: 'pointermove', - srcEvent, - center: [x, y], - offsetCenter: { x, y } - }; - - if (deck.props.onHover) { - deck.props.onHover(info, evt); - } - }, [x, y]); -} - -export async function deckDrag( - page: Page, - start: { x: number; y: number }, - end: { x: number; y: number }, - steps: number = 5 -) { - await page.evaluate(([start, end, steps]) => { - const deck = (window as any).__deck; - if (!deck) { - throw new Error('No deck instance on window'); - } - - const makeEvent = (type: string, x: number, y: number, buttons: number) => { - const srcEvent = new PointerEvent(type, { - clientX: x, - clientY: y, - buttons, - pointerType: 'mouse', - bubbles: true - }); - return { - type, - srcEvent, - center: [x, y], - offsetCenter: { x, y } - }; - }; - - if (deck.props.onDragStart) { - deck.props.onDragStart(makeEvent('pointerdown', start.x, start.y, 1)); - } - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const x = start.x + (end.x - start.x) * t; - const y = start.y + (end.y - start.y) * t; - - if (deck.props.onDrag) { - deck.props.onDrag(makeEvent('pointermove', x, y, 1)); - } - } - - if (deck.props.onDragEnd) { - deck.props.onDragEnd(makeEvent('pointerup', end.x, end.y, 0)); - } - }, [start, end, steps]); -} - -export async function waitForDeck(page: Page, timeout: number = 10000): Promise { - await page.waitForFunction( - () => typeof (window as any).__deck !== 'undefined', - { timeout } - ); -} diff --git a/tests/e2e/helpers/deckgl/index.ts b/tests/e2e/helpers/deckgl/index.ts new file mode 100644 index 00000000..df349128 --- /dev/null +++ b/tests/e2e/helpers/deckgl/index.ts @@ -0,0 +1,6 @@ +/** + * DeckGL-specific test helpers for Playwright e2e tests. + */ + +export * from "./types"; +export * from "./interactions"; diff --git a/tests/e2e/helpers/deckgl/interactions.ts b/tests/e2e/helpers/deckgl/interactions.ts new file mode 100644 index 00000000..771b6468 --- /dev/null +++ b/tests/e2e/helpers/deckgl/interactions.ts @@ -0,0 +1,207 @@ +import type { Page } from "@playwright/test"; +import type { + PickingInfo, + DeckGLInstance, + WindowWithDeck, + Coordinate, + DeckGLEvent, +} from "./types"; + +/** + * @fileoverview DeckGL canvas interaction helpers for Playwright e2e tests. + * + * These helpers work by accessing the DeckGL instance via `window.__deck`, which is + * exposed in src/index.tsx:100 for testing purposes. This allows tests to directly + * invoke DeckGL's event handlers (onClick, onHover, etc.) with properly constructed + * event payloads, bypassing Playwright's DOM event system which doesn't reliably + * trigger DeckGL's event pipeline. + * + * All coordinate parameters use canvas-relative pixel coordinates (offsets from the + * canvas element's top-left corner), NOT page-relative coordinates. + * + * ## Common patterns used in browser context (inside page.evaluate): + * + * 1. **Access DeckGL instance:** + * ```ts + * const deck = (window as any).__deck; + * if (!deck) throw new Error("No deck instance on window"); + * ``` + * + * 2. **Unproject pixel coordinates to geographic coordinates:** + * ```ts + * const coordinate = (() => { + * if (info?.coordinate) return info.coordinate; + * try { + * const viewport = deck.getViewports()?.[0]; + * return viewport?.unproject?.([x, y]); + * } catch (e) { + * console.error("Failed to unproject coordinates:", e); + * } + * })(); + * ``` + */ + +/** + * Simulates pointer interactions on the DeckGL canvas at the specified coordinates. + * + * @param page - Playwright page object + * @param action - Type of pointer event: "click" or "hover" + * @param x - X coordinate in canvas-relative pixels (offset from canvas left edge) + * @param y - Y coordinate in canvas-relative pixels (offset from canvas top edge) + * + * @remarks + * Coordinates are canvas-relative, meaning they are pixel offsets from the top-left + * corner of the canvas element itself, NOT from the page. Even if the canvas is + * scrolled or positioned elsewhere in the page, always use canvas-relative coordinates. + * + * The function works by: + * 1. Using DeckGL's pickObject() to get object info at the position + * 2. Converting pixel coordinates to geographic coordinates via viewport.unproject() + * 3. Building a proper PickingInfo object with coordinate data + * 4. Invoking the appropriate DeckGL event handler (onClick or onHover) + * + * This approach bypasses the DOM event system entirely and works directly with + * DeckGL's event handlers, which is necessary because Playwright's mouse events + * don't reliably trigger DeckGL's event pipeline. + * + * @example + * ```typescript + * // Simulate a click + * await deckPointerEvent(page, "click", 200, 300); + * + * // Simulate a hover + * await deckPointerEvent(page, "hover", 400, 500); + * ``` + */ +export async function deckPointerEvent( + page: Page, + action: "click" | "hover", + x: number, + y: number, +): Promise { + await page.evaluate( + (args) => { + const { action, x, y } = args; + const win = window as unknown as WindowWithDeck; + const deck: DeckGLInstance = win.__deck; + + if (!deck) { + throw new Error("No deck instance on window"); + } + + let info: PickingInfo | undefined = deck.pickObject?.({ + x, + y, + radius: 2, + }); + + const coordinate: Coordinate | undefined = (() => { + if (info?.coordinate) return info.coordinate; + try { + const viewport = deck.getViewports?.()?.[0]; + return viewport?.unproject?.([x, y]); + } catch (e) { + console.error("Failed to unproject coordinates:", e); + return undefined; + } + })(); + + if (!info) { + info = { + x, + y, + object: null, + layer: null, + index: -1, + coordinate: action === "click" ? coordinate || [0, 0] : coordinate, + ...(action === "click" ? { pixel: [x, y] as [number, number] } : {}), + }; + } else if (!info.coordinate && coordinate) { + info.coordinate = coordinate; + } + + const isClick = action === "click"; + const type = isClick ? "click" : "pointermove"; + const buttons = isClick ? 1 : 0; + const srcEvent = new PointerEvent(type, { + clientX: x, + clientY: y, + buttons, + pointerType: "mouse", + bubbles: true, + }); + const evt: DeckGLEvent = { + type, + srcEvent, + center: [x, y], + offsetCenter: { x, y }, + }; + + if (isClick) { + deck.props.onClick?.(info, evt); + } else { + deck.props.onHover?.(info, evt); + } + }, + { action, x, y }, + ); +} + +export async function deckDrag( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, + steps: number = 5, +) { + await page.evaluate( + (args) => { + const { start, end, steps } = args; + const win = window as unknown as WindowWithDeck; + const deck: DeckGLInstance = win.__deck; + + if (!deck) { + throw new Error("No deck instance on window"); + } + + const makeEvent = ( + type: string, + x: number, + y: number, + buttons: number, + ): DeckGLEvent => { + const srcEvent = new PointerEvent(type, { + clientX: x, + clientY: y, + buttons, + pointerType: "mouse", + bubbles: true, + }); + return { + type, + srcEvent, + center: [x, y], + offsetCenter: { x, y }, + }; + }; + + if (deck.props.onDragStart) { + deck.props.onDragStart(makeEvent("pointerdown", start.x, start.y, 1)); + } + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = start.x + (end.x - start.x) * t; + const y = start.y + (end.y - start.y) * t; + + if (deck.props.onDrag) { + deck.props.onDrag(makeEvent("pointermove", x, y, 1)); + } + } + + if (deck.props.onDragEnd) { + deck.props.onDragEnd(makeEvent("pointerup", end.x, end.y, 0)); + } + }, + { start, end, steps }, + ); +} diff --git a/tests/e2e/helpers/deckgl/types.ts b/tests/e2e/helpers/deckgl/types.ts new file mode 100644 index 00000000..7c12431d --- /dev/null +++ b/tests/e2e/helpers/deckgl/types.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview Type definitions for DeckGL structures used in e2e tests. + * + * These types describe the runtime DeckGL objects accessed via window.__deck. + * They are not exhaustive - only the properties we use in tests are typed. + */ + +/** + * Geographic coordinate [longitude, latitude] + */ +export type Coordinate = [number, number]; + +/** + * Pixel coordinate [x, y] + */ +export type PixelCoordinate = [number, number]; + +/** + * Information about what was picked at a given screen coordinate. + * Returned by deck.pickObject() and passed to event handlers. + */ +export interface PickingInfo { + x: number; + y: number; + object: unknown | null; + layer: unknown | null; + index: number; + coordinate?: Coordinate; + pixel?: PixelCoordinate; +} + +/** + * DeckGL viewport for coordinate projection/unprojection + */ +export interface Viewport { + unproject?: (pixelCoords: PixelCoordinate) => Coordinate; +} + +/** + * DeckGL event handlers + */ +export interface DeckGLProps { + onClick?: (info: PickingInfo, event: DeckGLEvent) => void; + onHover?: (info: PickingInfo, event: DeckGLEvent) => void; + onDragStart?: (event: DeckGLEvent) => void; + onDrag?: (event: DeckGLEvent) => void; + onDragEnd?: (event: DeckGLEvent) => void; +} + +/** + * DeckGL event payload passed to handlers + */ +export interface DeckGLEvent { + type: string; + srcEvent: PointerEvent; + center: PixelCoordinate; + offsetCenter: { x: number; y: number }; +} + +/** + * The DeckGL instance exposed on window for testing + */ +export interface DeckGLInstance { + pickObject?: (options: { + x: number; + y: number; + radius?: number; + }) => PickingInfo | undefined; + getViewports?: () => Viewport[] | undefined; + props: DeckGLProps; +} + +/** + * Window with DeckGL instance attached for testing + */ +export interface WindowWithDeck extends Window { + __deck: DeckGLInstance; +} diff --git a/tests/e2e/helpers/notebook.ts b/tests/e2e/helpers/notebook.ts index a631c3bc..2767b810 100644 --- a/tests/e2e/helpers/notebook.ts +++ b/tests/e2e/helpers/notebook.ts @@ -1,24 +1,116 @@ +/** + * @fileoverview JupyterLab notebook helpers for Playwright e2e tests. + * + * These helpers provide utilities for interacting with JupyterLab notebooks, + * including opening notebooks, executing cells, and waiting for map widgets + * to be ready for interaction. + */ + import { expect, type Page, type Locator } from "@playwright/test"; -import { waitForDeck } from "./deck-interaction"; -export async function openNotebook(page: Page, notebookPath: string): Promise { +import type { WindowWithDeck } from "./deckgl"; + +/** + * Opens a notebook in JupyterLab and waits for it to be visible. + * + * @param page - Playwright page object + * @param notebookPath - Path to the notebook relative to JupyterLab root + * @returns Locator for the notebook element + * + * @example + * ```typescript + * const notebook = await openNotebook(page, "simple-map.ipynb"); + * ``` + */ +export async function openNotebook( + page: Page, + notebookPath: string, +): Promise { await page.goto(`/lab/tree/${notebookPath}`); const notebook = page.locator(".jp-Notebook"); await expect(notebook).toBeVisible({ timeout: 30000 }); return notebook; } -export async function runCell(notebook: Locator, cellIndex: number): Promise { +/** + * Executes a single notebook cell by clicking it and pressing Shift+Enter. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell to execute + * + * @example + * ```typescript + * await runCell(notebook, 0); // Run first cell + * ``` + */ +export async function runCell( + notebook: Locator, + cellIndex: number, +): Promise { await notebook.locator(".jp-Cell").nth(cellIndex).click(); await notebook.page().keyboard.press("Shift+Enter"); } -export async function runCells(notebook: Locator, startIndex: number, count: number): Promise { +/** + * Executes multiple consecutive notebook cells starting from a given index. + * + * @param notebook - The notebook locator + * @param startIndex - Zero-based index of the first cell to execute + * @param count - Number of cells to execute + * + * @example + * ```typescript + * await runCells(notebook, 0, 3); // Run cells 0, 1, and 2 + * ``` + */ +export async function runCells( + notebook: Locator, + startIndex: number, + count: number, +): Promise { await notebook.locator(".jp-Cell").nth(startIndex).click(); for (let i = 0; i < count; i++) { await notebook.page().keyboard.press("Shift+Enter"); } } +/** + * Waits for the DeckGL instance to be available on the window object. + * + * @param page - Playwright page object + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + */ +export async function waitForDeck( + page: Page, + timeout: number = 10000, +): Promise { + await page.waitForFunction( + () => { + const win = window as unknown as WindowWithDeck; + return typeof win.__deck !== "undefined"; + }, + { timeout }, + ); +} + +/** + * Waits for a Lonboard map widget to be fully ready for interaction. + * + * @param page - Playwright page object + * + * @remarks + * This function performs multiple checks to ensure the map is ready: + * 1. Waits for the map root element with context menu suppression + * 2. Waits for the DeckGL canvas overlay to be visible + * 3. Polls until the canvas has non-zero dimensions + * 4. Waits for the DeckGL instance to be available on window.__deck + * + * @example + * ```typescript + * await runCells(notebook, 0, 2); + * await waitForMapReady(page); + * // Now safe to interact with the map + * ``` + */ export async function waitForMapReady(page: Page): Promise { const mapRoot = page.locator("[data-jp-suppress-context-menu]"); await expect(mapRoot.first()).toBeVisible({ timeout: 30000 }); @@ -27,15 +119,62 @@ export async function waitForMapReady(page: Page): Promise { await expect(deckCanvas).toBeVisible({ timeout: 30000 }); await expect - .poll(async () => { - const box = await deckCanvas.boundingBox(); - return box && box.width > 0 && box.height > 0; - }, { timeout: 10000 }) + .poll( + async () => { + const box = await deckCanvas.boundingBox(); + return box && box.width > 0 && box.height > 0; + }, + { timeout: 10000 }, + ) .toBe(true); await waitForDeck(page); } +/** + * Gets the output area locator for a specific notebook cell. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell + * @returns Locator for the cell's last output area + * + * @example + * ```typescript + * const output = getCellOutput(notebook, 2); + * const text = await output.textContent(); + * ``` + */ export function getCellOutput(notebook: Locator, cellIndex: number): Locator { - return notebook.locator(".jp-Cell").nth(cellIndex).locator(".jp-OutputArea-output").last(); + return notebook + .locator(".jp-Cell") + .nth(cellIndex) + .locator(".jp-OutputArea-output") + .last(); +} + +/** + * Executes a notebook cell and waits for its output to appear. + * + * @param notebook - The notebook locator + * @param cellIndex - Zero-based index of the cell to execute + * @returns Locator for the cell's output area + */ +export async function executeCellAndWaitForOutput( + notebook: Locator, + cellIndex: number, +): Promise { + await notebook.locator(".jp-Cell").nth(cellIndex).click(); + await notebook.page().keyboard.press("Shift+Enter"); + + // Wait for the cell to execute and produce output + await notebook.page().waitForTimeout(1000); + + const output = notebook + .locator(".jp-Cell") + .nth(cellIndex) + .locator(".jp-OutputArea-output") + .first(); + await expect(output).toBeVisible({ timeout: 5000 }); + + return output; } From d4d151d4327f6d11404f773b8ce2650ee78a4130 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 16:13:22 +0100 Subject: [PATCH 04/14] feat(globe): add GlobeWidget and workspace-based e2e; expose MapLibre for tests --- docs/api/map.md | 16 ++- lonboard/_map.py | 26 ++++ package-lock.json | 15 +++ package.json | 10 +- src/globe-map.tsx | 221 ++++++++++++++++++++++++++++++++ src/index.tsx | 45 +++++-- tests/e2e/bbox-select.spec.ts | 14 +- tests/e2e/globe-view.spec.ts | 73 +++++++++++ tests/e2e/helpers/notebook.ts | 72 ++++++++++- tests/e2e/notebook-load.spec.ts | 20 ++- 10 files changed, 472 insertions(+), 40 deletions(-) create mode 100644 src/globe-map.tsx create mode 100644 tests/e2e/globe-view.spec.ts diff --git a/docs/api/map.md b/docs/api/map.md index 9e397374..aaedbaed 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -3,11 +3,23 @@ -::: lonboard.Map +::::: lonboard.Map options: group_by_category: false show_bases: false filters: +::::: lonboard.models.ViewState +## Projection -::: lonboard.models.ViewState +Lonboard supports selecting the base map projection via the `projection` trait on `Map`: + +```python +from lonboard import Map + +m = Map(layers=[], projection="globe") # or "mercator" (default) +``` + +- When `projection="globe"`, Lonboard renders a 3D globe using MapLibre GL JS (v5+). +- The initial globe support is minimal by design: basemap + data layers only. +- Interactive features and UI controls are equivalent to the default map where applicable, but globe-specific interactions may differ. diff --git a/lonboard/_map.py b/lonboard/_map.py index 9979e476..df44b005 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -268,6 +268,32 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: ``` """ + projection = t.Unicode("mercator").tag(sync=True) + """ + Map projection type. + + - Type: `str` + - Options: `"mercator"` (default) or `"globe"` + - Default: `"mercator"` + + The globe projection displays the map on a 3D globe, providing a more realistic + representation of the Earth. This feature requires MapLibre GL JS 5.0+. + + **Example:** + + ```py + m = Map( + layers, + projection="globe" + ) + ``` + + !!! note + Globe projection may behave differently than Mercator projection at certain + zoom levels. Some rendering features may have different performance + characteristics in globe mode. + """ + # TODO: We'd prefer a "Strict union of bool and float" but that doesn't # work here because `Union[bool, float]` would coerce `1` to `True`, which we don't # want, and `Union[float, bool]` would coerce `True` to `1`, which we also don't diff --git a/package-lock.json b/package-lock.json index b16fab05..fcd22053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@deck.gl/core": "^9.1.14", "@deck.gl/extensions": "^9.1.14", "@deck.gl/layers": "^9.1.14", + "@deck.gl/mapbox": "^9.1.15", "@deck.gl/react": "^9.1.14", "@geoarrow/deck.gl-layers": "^0.3.1", "@nextui-org/react": "^2.4.8", @@ -1399,6 +1400,20 @@ "@math.gl/core": "4.1.0" } }, + "node_modules/@deck.gl/mapbox": { + "version": "9.1.15", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.1.15.tgz", + "integrity": "sha512-XnQKlQUOYx27+m2WJqYMFeDv36yGkUPbbra5bb1j+jDLA11GRBweYM/rq9JtrdWpfcdtJTmLnpeeVPJV8Bm3/Q==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "~9.1.9", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "^9.1.0", + "@luma.gl/core": "~9.1.9" + } + }, "node_modules/@deck.gl/mesh-layers": { "version": "9.1.14", "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.1.14.tgz", diff --git a/package.json b/package.json index 70a08139..beef8b6b 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "dependencies": { "@anywidget/react": "^0.2.0", "@babel/runtime": "^7.28.4", - "@deck.gl/core": "^9.1.14", - "@deck.gl/extensions": "^9.1.14", - "@deck.gl/layers": "^9.1.14", - "@deck.gl/react": "^9.1.14", + "@deck.gl/core": "^9.1.15", + "@deck.gl/extensions": "^9.1.15", + "@deck.gl/layers": "^9.1.15", + "@deck.gl/mapbox": "^9.1.15", + "@deck.gl/react": "^9.1.15", "@geoarrow/deck.gl-layers": "^0.3.1", "@nextui-org/react": "^2.4.8", "@xstate/react": "^6.0.0", @@ -65,6 +66,7 @@ "prettier": "prettier './src/**/*.{ts,tsx,css}' --write", "lint": "eslint src", "test": "vitest run", + "pretest:e2e": "npm run build", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "jupyter:test": "uv run --group dev jupyter lab --no-browser --port=8889 --notebook-dir=tests/e2e/fixtures --IdentityProvider.token=''" diff --git a/src/globe-map.tsx b/src/globe-map.tsx new file mode 100644 index 00000000..ddf203ae --- /dev/null +++ b/src/globe-map.tsx @@ -0,0 +1,221 @@ +/** + * Globe components for MapLibre globe projection with DeckGL overlay. + * - GlobeMap: low-level MapLibre + MapboxOverlay bridge + * - GlobeWidget: minimal widget wrapper (basemap + data layers) + */ + +import * as React from "react"; +import { useEffect, useState } from "react"; +import Map, { useControl, type MapRef } from "react-map-gl/maplibre"; +import { type Layer, type MapViewState } from "@deck.gl/core"; +import { + MapboxOverlay as DeckOverlay, + MapboxOverlayProps, +} from "@deck.gl/mapbox"; +import { useModel, useModelState } from "@anywidget/react"; +import type { WidgetModel, IWidgetManager } from "@jupyter-widgets/base"; +import { BaseLayerModel, initializeLayer } from "./model/index.js"; +import { isDefined, loadChildModels } from "./util.js"; +import { useViewStateDebounced } from "./state"; + +import "maplibre-gl/dist/maplibre-gl.css"; + +const DEFAULT_INITIAL_VIEW_STATE = { + latitude: 10, + longitude: 0, + zoom: 0.5, + bearing: 0, + pitch: 0, +}; + +const DEFAULT_MAP_STYLE = + "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json"; + +/** Integrates DeckGL with MapLibre via MapboxOverlay. */ +function DeckGLOverlay(props: MapboxOverlayProps) { + const overlay = useControl(() => new DeckOverlay(props)); + overlay.setProps(props); + return null; +} + +interface GlobeMapProps { + /** Initial view state for the map */ + initialViewState?: { + latitude?: number; + longitude?: number; + zoom?: number; + bearing?: number; + pitch?: number; + }; + /** MapLibre style URL */ + mapStyle?: string; + /** DeckGL layers to render */ + layers?: Layer[]; + /** Custom attribution string */ + customAttribution?: string; + /** Whether to use device pixels */ + useDevicePixels?: boolean; + /** Picking radius in pixels */ + pickingRadius?: number; + /** Map ID for testing purposes */ + mapId?: string; +} + +/** GlobeMap: renders a MapLibre map with a DeckGL overlay. */ +export function GlobeMap({ + initialViewState = DEFAULT_INITIAL_VIEW_STATE, + mapStyle = DEFAULT_MAP_STYLE, + layers = [], + customAttribution, + useDevicePixels = true, + pickingRadius = 5, + mapId = "globe-map", +}: GlobeMapProps) { + const mapRef = React.useRef(null); + const containerRef = React.useRef(null); + + return ( +
+ { + // Expose underlying MapLibre instance on the globe container for tests + const mapInstance = (e as unknown as { target?: unknown }).target; + if (containerRef.current) { + (containerRef.current as unknown as { _map?: unknown })._map = + mapInstance as unknown; + } + }} + > + + +
+ ); +} + +export default GlobeMap; + +// Minimal widget wrapper for globe mode with only basemap + data layers +export function GlobeWidget() { + const model = useModel(); + const [mapStyle] = useModelState("basemap_style"); + const [mapHeight] = useModelState("height"); + const [pickingRadius] = useModelState("picking_radius"); + const [useDevicePixels] = useModelState( + "use_device_pixels", + ); + const [customAttribution] = useModelState("custom_attribution"); + + const [initialViewState] = useViewStateDebounced("view_state"); + + const [subModelState, setSubModelState] = useState< + Record + >({}); + const [childLayerIds] = useModelState("layers"); + + // Minimal child model loader (no extra features) + async function getChildModelState( + childModels: WidgetModel[], + childLayerIdsLocal: string[], + previousSubModelState: Record, + setStateCounter: React.Dispatch>, + ): Promise> { + const newSubModelState: Record = {}; + const updateStateCallback = () => setStateCounter(new Date()); + for (let i = 0; i < childLayerIdsLocal.length; i++) { + const childLayerId = childLayerIdsLocal[i]; + const childModel = childModels[i]; + if (childLayerId in previousSubModelState) { + newSubModelState[childLayerId] = previousSubModelState[childLayerId]; + delete previousSubModelState[childLayerId]; + continue; + } + const childLayer = await initializeLayer(childModel, updateStateCallback); + newSubModelState[childLayerId] = childLayer; + } + for (const prev of Object.values(previousSubModelState)) { + prev.finalize(); + } + return newSubModelState; + } + + // Fake state to trigger re-render when model callbacks fire + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [stateCounter, setStateCounter] = useState(new Date()); + + useEffect(() => { + const loadAndUpdateLayers = async () => { + try { + const childModels = await loadChildModels( + model.widget_manager as unknown as IWidgetManager, + childLayerIds, + ); + const newSubModelState = await getChildModelState( + childModels, + childLayerIds, + subModelState, + setStateCounter, + ); + setSubModelState(newSubModelState); + } catch (e) { + console.error("Error loading child models (globe)", e); + } + }; + loadAndUpdateLayers(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore react-hooks/exhaustive-deps rule configured in repo + }, [childLayerIds]); + + const layers: Layer[] = []; + for (const subModel of Object.values(subModelState)) { + layers.push(subModel.render()); + } + + return ( +
+
+ + Object.keys(initialViewState).includes(key), + ) + ? initialViewState + : DEFAULT_INITIAL_VIEW_STATE + } + mapId="globe-map" + /> +
+
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index dbb697d8..5ebe8473 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,7 +6,7 @@ import Map from "react-map-gl/maplibre"; import DeckGL from "@deck.gl/react"; import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; import { BaseLayerModel, initializeLayer } from "./model/index.js"; -import type { WidgetModel } from "@jupyter-widgets/base"; +import type { WidgetModel, IWidgetManager } from "@jupyter-widgets/base"; import { initParquetWasm } from "./parquet.js"; import { isDefined, loadChildModels } from "./util.js"; import { v4 as uuidv4 } from "uuid"; @@ -24,6 +24,8 @@ import Toolbar from "./toolbar.js"; import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; +import { GlobeWidget } from "./globe-map.js"; +import { DeckGLRef, ViewOrViews } from "@deck.gl/react/dist/deckgl.js"; await initParquetWasm(); @@ -94,10 +96,11 @@ function App() { const [justClicked, setJustClicked] = useState(false); - const deckRef = React.useRef(null); + const deckRef = React.useRef(null); useEffect(() => { if (deckRef.current && typeof window !== "undefined") { - (window as any).__deck = deckRef.current.deck; + (window as unknown as Record).__deck = + deckRef.current.deck; } }, [deckRef.current]); @@ -153,7 +156,7 @@ function App() { const loadAndUpdateLayers = async () => { try { const childModels = await loadChildModels( - model.widget_manager, + model.widget_manager as unknown as IWidgetManager, childLayerIds, ); @@ -245,7 +248,7 @@ function App() { )}
>} style={{ width: "100%", height: "100%" }} initialViewState={ ["longitude", "latitude", "zoom"].every((key) => @@ -293,7 +296,7 @@ function App() { >
@@ -302,13 +305,29 @@ function App() { ); } -const WrappedApp = () => ( - - - - - -); +const WrappedApp = () => { + const [projection] = useModelState("projection"); + + // Route to globe implementation if projection is "globe" + if (projection === "globe") { + return ( + + + + + + ); + } + + // Default: render the standard flat map + return ( + + + + + + ); +}; const module: { render: Render; initialize?: Initialize } = { render: createRender(WrappedApp), diff --git a/tests/e2e/bbox-select.spec.ts b/tests/e2e/bbox-select.spec.ts index fb3a44c7..7bcd286a 100644 --- a/tests/e2e/bbox-select.spec.ts +++ b/tests/e2e/bbox-select.spec.ts @@ -1,9 +1,8 @@ import { test, expect, Page } from "@playwright/test"; import { deckPointerEvent } from "./helpers/deckgl"; import { - openNotebook, - runCells, - waitForMapReady, + openNotebookFresh, + runFirstNCells, executeCellAndWaitForOutput, } from "./helpers/notebook"; import { validateBounds } from "./helpers/assertions"; @@ -31,9 +30,10 @@ async function drawBbox( test.describe("BBox selection", () => { test("draws bbox and syncs selected_bounds to Python", async ({ page }) => { - const notebook = await openNotebook(page, "simple-map.ipynb"); - await runCells(notebook, 0, 2); - await waitForMapReady(page); + const { notebookRoot } = await openNotebookFresh(page, "simple-map.ipynb", { + workspaceId: `bbox-${Date.now()}`, + }); + await runFirstNCells(page, notebookRoot, 2); await page.waitForTimeout(2000); // Start bbox selection mode @@ -55,7 +55,7 @@ test.describe("BBox selection", () => { await expect(clearButton).toBeVisible({ timeout: 2000 }); // Execute cell to check selected bounds - const output = await executeCellAndWaitForOutput(notebook, 2); + const output = await executeCellAndWaitForOutput(notebookRoot, 2); // Verify bounds are valid geographic coordinates const outputText = await output.textContent(); diff --git a/tests/e2e/globe-view.spec.ts b/tests/e2e/globe-view.spec.ts new file mode 100644 index 00000000..48146a75 --- /dev/null +++ b/tests/e2e/globe-view.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; +import { + openNotebookFresh, + runFirstNCells, + ignoreKnownWidgetNoise, +} from "./helpers/notebook"; + +/** Globe projection e2e checks (minimal). */ + +test.describe("Globe View", () => { + // TODO: Fix test isolation - these tests fail when multiple notebooks are open + // The test environment needs to ensure a clean JupyterLab state between tests + + test("renders map with globe projection", async ({ page }) => { + ignoreKnownWidgetNoise(page); + + // Open the notebook in a fresh workspace and focus it deterministically + const { notebookRoot } = await openNotebookFresh(page, "globe-view.ipynb", { + workspaceId: `globe-${Date.now()}`, + }); + + // Run first two cells + await runFirstNCells(page, notebookRoot, 2); + + // Wait for globe container + const globeContainer = page.locator("#globe-map").first(); + await expect(globeContainer).toBeVisible({ timeout: 30000 }); + + // Ensure globe container has rendered dimensions + await expect + .poll( + async () => { + const box = await globeContainer.boundingBox(); + return box && box.width > 0 && box.height > 0; + }, + { timeout: 10000 }, + ) + .toBe(true); + + // Poll until MapLibre reports globe projection + await expect + .poll( + async () => { + return await page.evaluate(() => { + const container = document.querySelector("#globe-map") as + | (HTMLElement & { + _map?: { getProjection?: () => { type: string } }; + }) + | null; + if (!container) return null; + const map = container._map; + if (!map || !map.getProjection) return null; + return map.getProjection().type; + }); + }, + { timeout: 10000 }, + ) + .toBe("globe"); + }); + + test("layers render on globe canvas", async ({ page }) => { + const { notebookRoot } = await openNotebookFresh(page, "globe-view.ipynb"); + await runFirstNCells(page, notebookRoot, 2); + + // Use MapLibre canvas (MapboxOverlay path) to verify rendering + const mlCanvas = page.locator("canvas.maplibregl-canvas").first(); + await expect(mlCanvas).toBeVisible({ timeout: 30000 }); + const mlBox = await mlCanvas.boundingBox(); + expect(mlBox).toBeTruthy(); + expect(mlBox!.width).toBeGreaterThan(0); + expect(mlBox!.height).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/helpers/notebook.ts b/tests/e2e/helpers/notebook.ts index 2767b810..9b1b7450 100644 --- a/tests/e2e/helpers/notebook.ts +++ b/tests/e2e/helpers/notebook.ts @@ -26,11 +26,69 @@ export async function openNotebook( notebookPath: string, ): Promise { await page.goto(`/lab/tree/${notebookPath}`); - const notebook = page.locator(".jp-Notebook"); + + // Wait for notebook to load and get the visible one + // After navigation, JupyterLab should show the requested notebook + const notebook = page.locator(".jp-Notebook").first(); await expect(notebook).toBeVisible({ timeout: 30000 }); + return notebook; } +export type OpenNotebookOptions = { + workspaceId?: string; + timeoutMs?: number; +}; + +/** + * Robustly opens a notebook in a fresh JupyterLab workspace using command registry. + * Avoids hidden tabs and layout restore flakiness. + */ +export async function openNotebookFresh( + page: Page, + notebookPath: string, + opts: OpenNotebookOptions = {}, +): Promise<{ notebookRoot: Locator }> { + const { + workspaceId = `e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timeoutMs = 60000, + } = opts; + + // Load a unique workspace and directly open the target notebook URL within it + await page.goto( + `/lab/workspaces/${encodeURIComponent(workspaceId)}/tree/${notebookPath}`, + { waitUntil: "networkidle" }, + ); + + // Wait for the visible notebook element + const notebookRoot = page.locator(".jp-Notebook").first(); + await expect(notebookRoot).toBeVisible({ timeout: timeoutMs }); + return { notebookRoot }; +} + +/** Runs the first N cells via JupyterLab APIs to avoid keybinding flakiness. */ +export async function runFirstNCells( + page: Page, + notebook: Locator, + n = 2, +): Promise { + await notebook.locator(".jp-Cell").first().click(); + for (let i = 0; i < n; i++) { + await notebook.page().keyboard.press("Shift+Enter"); + } +} + +/** Filters known benign widget noise from console output. */ +export function ignoreKnownWidgetNoise(page: Page): void { + page.on("console", (msg) => { + const t = msg.text() || ""; + if (t.includes("Error displaying widget: model not found")) return; + if (t.includes("Widget model not found")) return; + // Forward other logs for debugging + console.log("[browser]", msg.type(), t); + }); +} + /** * Executes a single notebook cell by clicking it and pressing Shift+Enter. * @@ -112,16 +170,16 @@ export async function waitForDeck( * ``` */ export async function waitForMapReady(page: Page): Promise { - const mapRoot = page.locator("[data-jp-suppress-context-menu]"); - await expect(mapRoot.first()).toBeVisible({ timeout: 30000 }); - - const deckCanvas = page.locator('[id^="map-"] canvas#deckgl-overlay').first(); - await expect(deckCanvas).toBeVisible({ timeout: 30000 }); + // Accept either DeckGL overlay or MapLibre canvas as readiness signal + const canvas = page + .locator("canvas#deckgl-overlay, canvas.maplibregl-canvas") + .first(); + await expect(canvas).toBeVisible({ timeout: 30000 }); await expect .poll( async () => { - const box = await deckCanvas.boundingBox(); + const box = await canvas.boundingBox(); return box && box.width > 0 && box.height > 0; }, { timeout: 10000 }, diff --git a/tests/e2e/notebook-load.spec.ts b/tests/e2e/notebook-load.spec.ts index aed42fca..112849ff 100644 --- a/tests/e2e/notebook-load.spec.ts +++ b/tests/e2e/notebook-load.spec.ts @@ -1,11 +1,17 @@ -import { test, expect } from '@playwright/test'; -import { openNotebook } from './helpers/notebook'; +import { test, expect } from "@playwright/test"; +import { openNotebookFresh } from "./helpers/notebook"; -test.describe('Notebook Load', () => { - test('JupyterLab starts and loads notebook', async ({ page }) => { - await openNotebook(page, 'simple-map.ipynb'); +test.describe("Notebook Load", () => { + test("JupyterLab starts and loads notebook", async ({ page }) => { + await openNotebookFresh(page, "simple-map.ipynb", { + workspaceId: `load-${Date.now()}`, + }); - await expect(page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('text=/Python 3.*Idle/')).toBeVisible({ timeout: 30000 }); + await expect( + page.locator('.jp-mod-current[role="tab"]:has-text("simple-map.ipynb")'), + ).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=/Python 3.*Idle/")).toBeVisible({ + timeout: 30000, + }); }); }); From 2ecfc40045cfa3db75c9e562535aa3f691a4bf8e Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 16:50:33 +0100 Subject: [PATCH 05/14] test: exclude Playwright e2e tests from vitest Add vitest configuration to exclude tests/e2e/** directory from vitest runs. This prevents vitest from attempting to execute Playwright test files, which was causing CI failures. The e2e tests should only be run via the dedicated test:e2e npm script which uses Playwright's test runner. --- vitest.config.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 vitest.config.ts diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..0948d873 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/cypress/**", + "**/.{idea,git,cache,output,temp}/**", + "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*", + "**/tests/e2e/**", // Exclude Playwright e2e tests + ], + }, +}); From 6cc013082607fac6456c4ccfafc32ba195eff01b Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 17:27:06 +0100 Subject: [PATCH 06/14] fix(docs): correct mkdocstrings syntax and auto-generate projection trait docs --- docs/api/map.md | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/api/map.md b/docs/api/map.md index aaedbaed..6c9cf768 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -3,23 +3,8 @@ -::::: lonboard.Map +::: lonboard.Map options: group_by_category: false show_bases: false filters: -::::: lonboard.models.ViewState - -## Projection - -Lonboard supports selecting the base map projection via the `projection` trait on `Map`: - -```python -from lonboard import Map - -m = Map(layers=[], projection="globe") # or "mercator" (default) -``` - -- When `projection="globe"`, Lonboard renders a 3D globe using MapLibre GL JS (v5+). -- The initial globe support is minimal by design: basemap + data layers only. -- Interactive features and UI controls are equivalent to the default map where applicable, but globe-specific interactions may differ. From cdf80b8372920b23ebb67220cac41d6f8c7795d5 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 18:10:16 +0100 Subject: [PATCH 07/14] fix(docs): add models page to resolve ViewState cross-reference --- docs/api/models.md | 3 +++ mkdocs.yml | 1 + 2 files changed, 4 insertions(+) create mode 100644 docs/api/models.md diff --git a/docs/api/models.md b/docs/api/models.md new file mode 100644 index 00000000..20803474 --- /dev/null +++ b/docs/api/models.md @@ -0,0 +1,3 @@ +# Models + +::: lonboard.models.ViewState diff --git a/mkdocs.yml b/mkdocs.yml index caeeea30..98da32b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - API Reference: - api/viz.md - api/map.md + - api/models.md - Layers: - api/layers/bitmap-layer.md - api/layers/bitmap-tile-layer.md From 837c62830dc3fc7993b923de78fb259f4f4f5ff3 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Tue, 7 Oct 2025 18:15:35 +0100 Subject: [PATCH 08/14] fix(docs): restore map.md to main branch version to fix ViewState cross-reference --- docs/api/map.md | 3 +++ docs/api/models.md | 3 --- mkdocs.yml | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 docs/api/models.md diff --git a/docs/api/map.md b/docs/api/map.md index 6c9cf768..f80cf238 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -8,3 +8,6 @@ https://mkdocstrings.github.io/python/usage/configuration/members/#filters group_by_category: false show_bases: false filters: + + +::: lonboard.models.ViewState \ No newline at end of file diff --git a/docs/api/models.md b/docs/api/models.md deleted file mode 100644 index 20803474..00000000 --- a/docs/api/models.md +++ /dev/null @@ -1,3 +0,0 @@ -# Models - -::: lonboard.models.ViewState diff --git a/mkdocs.yml b/mkdocs.yml index 98da32b8..caeeea30 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,7 +60,6 @@ nav: - API Reference: - api/viz.md - api/map.md - - api/models.md - Layers: - api/layers/bitmap-layer.md - api/layers/bitmap-tile-layer.md From 3667c596607beccccbba6f62f4fc75bccb738034 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 17:55:00 +0100 Subject: [PATCH 09/14] revert: restore files to main branch versions --- docs/api/map.md | 2 +- lonboard/_map.py | 26 ----- src/globe-map.tsx | 221 ----------------------------------- src/index.tsx | 47 ++------ tests/e2e/globe-view.spec.ts | 73 ------------ 5 files changed, 11 insertions(+), 358 deletions(-) delete mode 100644 src/globe-map.tsx delete mode 100644 tests/e2e/globe-view.spec.ts diff --git a/docs/api/map.md b/docs/api/map.md index f80cf238..9e397374 100644 --- a/docs/api/map.md +++ b/docs/api/map.md @@ -10,4 +10,4 @@ https://mkdocstrings.github.io/python/usage/configuration/members/#filters filters: -::: lonboard.models.ViewState \ No newline at end of file +::: lonboard.models.ViewState diff --git a/lonboard/_map.py b/lonboard/_map.py index df44b005..9979e476 100644 --- a/lonboard/_map.py +++ b/lonboard/_map.py @@ -268,32 +268,6 @@ def on_click(self, callback: Callable, *, remove: bool = False) -> None: ``` """ - projection = t.Unicode("mercator").tag(sync=True) - """ - Map projection type. - - - Type: `str` - - Options: `"mercator"` (default) or `"globe"` - - Default: `"mercator"` - - The globe projection displays the map on a 3D globe, providing a more realistic - representation of the Earth. This feature requires MapLibre GL JS 5.0+. - - **Example:** - - ```py - m = Map( - layers, - projection="globe" - ) - ``` - - !!! note - Globe projection may behave differently than Mercator projection at certain - zoom levels. Some rendering features may have different performance - characteristics in globe mode. - """ - # TODO: We'd prefer a "Strict union of bool and float" but that doesn't # work here because `Union[bool, float]` would coerce `1` to `True`, which we don't # want, and `Union[float, bool]` would coerce `True` to `1`, which we also don't diff --git a/src/globe-map.tsx b/src/globe-map.tsx deleted file mode 100644 index ddf203ae..00000000 --- a/src/globe-map.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Globe components for MapLibre globe projection with DeckGL overlay. - * - GlobeMap: low-level MapLibre + MapboxOverlay bridge - * - GlobeWidget: minimal widget wrapper (basemap + data layers) - */ - -import * as React from "react"; -import { useEffect, useState } from "react"; -import Map, { useControl, type MapRef } from "react-map-gl/maplibre"; -import { type Layer, type MapViewState } from "@deck.gl/core"; -import { - MapboxOverlay as DeckOverlay, - MapboxOverlayProps, -} from "@deck.gl/mapbox"; -import { useModel, useModelState } from "@anywidget/react"; -import type { WidgetModel, IWidgetManager } from "@jupyter-widgets/base"; -import { BaseLayerModel, initializeLayer } from "./model/index.js"; -import { isDefined, loadChildModels } from "./util.js"; -import { useViewStateDebounced } from "./state"; - -import "maplibre-gl/dist/maplibre-gl.css"; - -const DEFAULT_INITIAL_VIEW_STATE = { - latitude: 10, - longitude: 0, - zoom: 0.5, - bearing: 0, - pitch: 0, -}; - -const DEFAULT_MAP_STYLE = - "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json"; - -/** Integrates DeckGL with MapLibre via MapboxOverlay. */ -function DeckGLOverlay(props: MapboxOverlayProps) { - const overlay = useControl(() => new DeckOverlay(props)); - overlay.setProps(props); - return null; -} - -interface GlobeMapProps { - /** Initial view state for the map */ - initialViewState?: { - latitude?: number; - longitude?: number; - zoom?: number; - bearing?: number; - pitch?: number; - }; - /** MapLibre style URL */ - mapStyle?: string; - /** DeckGL layers to render */ - layers?: Layer[]; - /** Custom attribution string */ - customAttribution?: string; - /** Whether to use device pixels */ - useDevicePixels?: boolean; - /** Picking radius in pixels */ - pickingRadius?: number; - /** Map ID for testing purposes */ - mapId?: string; -} - -/** GlobeMap: renders a MapLibre map with a DeckGL overlay. */ -export function GlobeMap({ - initialViewState = DEFAULT_INITIAL_VIEW_STATE, - mapStyle = DEFAULT_MAP_STYLE, - layers = [], - customAttribution, - useDevicePixels = true, - pickingRadius = 5, - mapId = "globe-map", -}: GlobeMapProps) { - const mapRef = React.useRef(null); - const containerRef = React.useRef(null); - - return ( -
- { - // Expose underlying MapLibre instance on the globe container for tests - const mapInstance = (e as unknown as { target?: unknown }).target; - if (containerRef.current) { - (containerRef.current as unknown as { _map?: unknown })._map = - mapInstance as unknown; - } - }} - > - - -
- ); -} - -export default GlobeMap; - -// Minimal widget wrapper for globe mode with only basemap + data layers -export function GlobeWidget() { - const model = useModel(); - const [mapStyle] = useModelState("basemap_style"); - const [mapHeight] = useModelState("height"); - const [pickingRadius] = useModelState("picking_radius"); - const [useDevicePixels] = useModelState( - "use_device_pixels", - ); - const [customAttribution] = useModelState("custom_attribution"); - - const [initialViewState] = useViewStateDebounced("view_state"); - - const [subModelState, setSubModelState] = useState< - Record - >({}); - const [childLayerIds] = useModelState("layers"); - - // Minimal child model loader (no extra features) - async function getChildModelState( - childModels: WidgetModel[], - childLayerIdsLocal: string[], - previousSubModelState: Record, - setStateCounter: React.Dispatch>, - ): Promise> { - const newSubModelState: Record = {}; - const updateStateCallback = () => setStateCounter(new Date()); - for (let i = 0; i < childLayerIdsLocal.length; i++) { - const childLayerId = childLayerIdsLocal[i]; - const childModel = childModels[i]; - if (childLayerId in previousSubModelState) { - newSubModelState[childLayerId] = previousSubModelState[childLayerId]; - delete previousSubModelState[childLayerId]; - continue; - } - const childLayer = await initializeLayer(childModel, updateStateCallback); - newSubModelState[childLayerId] = childLayer; - } - for (const prev of Object.values(previousSubModelState)) { - prev.finalize(); - } - return newSubModelState; - } - - // Fake state to trigger re-render when model callbacks fire - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [stateCounter, setStateCounter] = useState(new Date()); - - useEffect(() => { - const loadAndUpdateLayers = async () => { - try { - const childModels = await loadChildModels( - model.widget_manager as unknown as IWidgetManager, - childLayerIds, - ); - const newSubModelState = await getChildModelState( - childModels, - childLayerIds, - subModelState, - setStateCounter, - ); - setSubModelState(newSubModelState); - } catch (e) { - console.error("Error loading child models (globe)", e); - } - }; - loadAndUpdateLayers(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore react-hooks/exhaustive-deps rule configured in repo - }, [childLayerIds]); - - const layers: Layer[] = []; - for (const subModel of Object.values(subModelState)) { - layers.push(subModel.render()); - } - - return ( -
-
- - Object.keys(initialViewState).includes(key), - ) - ? initialViewState - : DEFAULT_INITIAL_VIEW_STATE - } - mapId="globe-map" - /> -
-
- ); -} diff --git a/src/index.tsx b/src/index.tsx index 5ebe8473..5d5a0f0b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,7 +6,7 @@ import Map from "react-map-gl/maplibre"; import DeckGL from "@deck.gl/react"; import { MapViewState, PickingInfo, type Layer } from "@deck.gl/core"; import { BaseLayerModel, initializeLayer } from "./model/index.js"; -import type { WidgetModel, IWidgetManager } from "@jupyter-widgets/base"; +import type { WidgetModel } from "@jupyter-widgets/base"; import { initParquetWasm } from "./parquet.js"; import { isDefined, loadChildModels } from "./util.js"; import { v4 as uuidv4 } from "uuid"; @@ -24,8 +24,6 @@ import Toolbar from "./toolbar.js"; import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; -import { GlobeWidget } from "./globe-map.js"; -import { DeckGLRef, ViewOrViews } from "@deck.gl/react/dist/deckgl.js"; await initParquetWasm(); @@ -96,14 +94,6 @@ function App() { const [justClicked, setJustClicked] = useState(false); - const deckRef = React.useRef(null); - useEffect(() => { - if (deckRef.current && typeof window !== "undefined") { - (window as unknown as Record).__deck = - deckRef.current.deck; - } - }, [deckRef.current]); - const model = useModel(); const [mapStyle] = useModelState("basemap_style"); @@ -156,7 +146,7 @@ function App() { const loadAndUpdateLayers = async () => { try { const childModels = await loadChildModels( - model.widget_manager as unknown as IWidgetManager, + model.widget_manager, childLayerIds, ); @@ -248,7 +238,6 @@ function App() { )}
>} style={{ width: "100%", height: "100%" }} initialViewState={ ["longitude", "latitude", "zoom"].every((key) => @@ -296,7 +285,7 @@ function App() { >
@@ -305,29 +294,13 @@ function App() { ); } -const WrappedApp = () => { - const [projection] = useModelState("projection"); - - // Route to globe implementation if projection is "globe" - if (projection === "globe") { - return ( - - - - - - ); - } - - // Default: render the standard flat map - return ( - - - - - - ); -}; +const WrappedApp = () => ( + + + + + +); const module: { render: Render; initialize?: Initialize } = { render: createRender(WrappedApp), diff --git a/tests/e2e/globe-view.spec.ts b/tests/e2e/globe-view.spec.ts deleted file mode 100644 index 48146a75..00000000 --- a/tests/e2e/globe-view.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - openNotebookFresh, - runFirstNCells, - ignoreKnownWidgetNoise, -} from "./helpers/notebook"; - -/** Globe projection e2e checks (minimal). */ - -test.describe("Globe View", () => { - // TODO: Fix test isolation - these tests fail when multiple notebooks are open - // The test environment needs to ensure a clean JupyterLab state between tests - - test("renders map with globe projection", async ({ page }) => { - ignoreKnownWidgetNoise(page); - - // Open the notebook in a fresh workspace and focus it deterministically - const { notebookRoot } = await openNotebookFresh(page, "globe-view.ipynb", { - workspaceId: `globe-${Date.now()}`, - }); - - // Run first two cells - await runFirstNCells(page, notebookRoot, 2); - - // Wait for globe container - const globeContainer = page.locator("#globe-map").first(); - await expect(globeContainer).toBeVisible({ timeout: 30000 }); - - // Ensure globe container has rendered dimensions - await expect - .poll( - async () => { - const box = await globeContainer.boundingBox(); - return box && box.width > 0 && box.height > 0; - }, - { timeout: 10000 }, - ) - .toBe(true); - - // Poll until MapLibre reports globe projection - await expect - .poll( - async () => { - return await page.evaluate(() => { - const container = document.querySelector("#globe-map") as - | (HTMLElement & { - _map?: { getProjection?: () => { type: string } }; - }) - | null; - if (!container) return null; - const map = container._map; - if (!map || !map.getProjection) return null; - return map.getProjection().type; - }); - }, - { timeout: 10000 }, - ) - .toBe("globe"); - }); - - test("layers render on globe canvas", async ({ page }) => { - const { notebookRoot } = await openNotebookFresh(page, "globe-view.ipynb"); - await runFirstNCells(page, notebookRoot, 2); - - // Use MapLibre canvas (MapboxOverlay path) to verify rendering - const mlCanvas = page.locator("canvas.maplibregl-canvas").first(); - await expect(mlCanvas).toBeVisible({ timeout: 30000 }); - const mlBox = await mlCanvas.boundingBox(); - expect(mlBox).toBeTruthy(); - expect(mlBox!.width).toBeGreaterThan(0); - expect(mlBox!.height).toBeGreaterThan(0); - }); -}); From 9029ff822f99925c5f8f088507d67492eafa0f4c Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 18:04:26 +0100 Subject: [PATCH 10/14] feat: add DeckGL ref exposure for Playwright tests --- src/index.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 5d5a0f0b..0e40fa6b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useEffect, useCallback, useState } from "react"; +import { useEffect, useCallback, useState, useRef } from "react"; import { createRender, useModelState, useModel } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; import Map from "react-map-gl/maplibre"; @@ -24,6 +24,7 @@ import Toolbar from "./toolbar.js"; import throttle from "lodash.throttle"; import SidePanel from "./sidepanel/index"; import { getTooltip } from "./tooltip/index.js"; +import { DeckGLRef } from "@deck.gl/react"; await initParquetWasm(); @@ -94,6 +95,14 @@ function App() { const [justClicked, setJustClicked] = useState(false); + const deckRef = useRef(null); + useEffect(() => { + if (deckRef.current && typeof window !== "undefined") { + (window as unknown as Record).__deck = + deckRef.current.deck; + } + }, [deckRef.current]); + const model = useModel(); const [mapStyle] = useModelState("basemap_style"); @@ -238,6 +247,7 @@ function App() { )}
From 59f17c858070bb9a5d7c184349f5ff3534a5c109 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 19:22:10 +0100 Subject: [PATCH 11/14] fix: formatting issues on fixture notebook --- tests/e2e/fixtures/simple-map.ipynb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/e2e/fixtures/simple-map.ipynb b/tests/e2e/fixtures/simple-map.ipynb index 09a9e580..3988540e 100644 --- a/tests/e2e/fixtures/simple-map.ipynb +++ b/tests/e2e/fixtures/simple-map.ipynb @@ -8,6 +8,7 @@ "source": [ "import geopandas as gpd\n", "from shapely.geometry import Point\n", + "\n", "from lonboard import Map, ScatterplotLayer" ] }, @@ -24,7 +25,7 @@ " Point(10, -10),\n", " Point(10, 10),\n", "]\n", - "gdf = gpd.GeoDataFrame(geometry=points, crs='EPSG:4326')\n", + "gdf = gpd.GeoDataFrame(geometry=points, crs=\"EPSG:4326\")\n", "\n", "layer = ScatterplotLayer.from_geopandas(\n", " gdf,\n", @@ -41,10 +42,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Check selected bounds (run after bbox selection)\n", - "print(f\"Selected bounds: {m.selected_bounds}\")" - ] + "source": "# Check selected bounds (run after bbox selection)\nf\"Selected bounds: {m.selected_bounds}\"" }, { "cell_type": "code", @@ -82,4 +80,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file From b4316a44f51c567a0ea63b87cf565df269cac163 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 19:32:51 +0100 Subject: [PATCH 12/14] fix: lint --- tests/e2e/fixtures/simple-map.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/fixtures/simple-map.ipynb b/tests/e2e/fixtures/simple-map.ipynb index 3988540e..38af05c1 100644 --- a/tests/e2e/fixtures/simple-map.ipynb +++ b/tests/e2e/fixtures/simple-map.ipynb @@ -80,4 +80,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} From 6a53a1a11550bd41b6e4f57c928bad6a62e146b6 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 19:47:32 +0100 Subject: [PATCH 13/14] fix: add comment, update .gitignore --- .gitignore | 2 ++ src/index.tsx | 1 + tests/e2e/fixtures/.gitignore | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 tests/e2e/fixtures/.gitignore diff --git a/.gitignore b/.gitignore index c2f96860..b03a1c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ node_modules # Playwright test artifacts test-results/ playwright-report/ +tests/e2e/fixtures/.ipynb_checkpoints + diff --git a/src/index.tsx b/src/index.tsx index 0e40fa6b..d33c2007 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -95,6 +95,7 @@ function App() { const [justClicked, setJustClicked] = useState(false); + // Expose DeckGL instance on window for Playwright e2e tests const deckRef = useRef(null); useEffect(() => { if (deckRef.current && typeof window !== "undefined") { diff --git a/tests/e2e/fixtures/.gitignore b/tests/e2e/fixtures/.gitignore deleted file mode 100644 index 87620ac7..00000000 --- a/tests/e2e/fixtures/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.ipynb_checkpoints/ From f1994580a06f6ed34f66cc26d84a516153b60e57 Mon Sep 17 00:00:00 2001 From: Vitor George Date: Thu, 9 Oct 2025 19:51:37 +0100 Subject: [PATCH 14/14] fix: lint --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index b03a1c0c..7f303e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,3 @@ node_modules test-results/ playwright-report/ tests/e2e/fixtures/.ipynb_checkpoints -