diff --git a/DEVELOP.md b/DEVELOP.md index 45bed095..70080be8 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -67,8 +67,6 @@ All models on the TypeScript side are combined into a single entry point, which Anywidget and its dependency ipywidgets handles the serialization from Python into JS, automatically keeping each side in sync. -State management is implemented using [XState](https://stately.ai/docs/xstate). The app is instrumented with [Stately Inspector](https://stately.ai/docs/inspector), and the use of the [VS Code extension](https://stately.ai/docs/xstate-vscode-extension) is highly recommended. - ## Publishing Push a new tag to the main branch of the format `v*`. A new version will be published to PyPI automatically. diff --git a/package-lock.json b/package-lock.json index a4f364ff..e0496962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@geoarrow/deck.gl-layers": "^0.4.0-beta.6", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", - "@xstate/react": "^6.0.0", "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", @@ -30,7 +29,7 @@ "react-map-gl": "^8.1.0", "threads": "1.7.0", "uuid": "^13.0.0", - "xstate": "^5.22.0" + "zustand": "^5.0.2" }, "devDependencies": { "@anywidget/types": "^0.2.0", @@ -7949,25 +7948,6 @@ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", "license": "MIT" }, - "node_modules/@xstate/react": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xstate/react/-/react-6.0.0.tgz", - "integrity": "sha512-xXlLpFJxqLhhmecAXclBECgk+B4zYSrDTl8hTfPZBogkn82OHKbm9zJxox3Z/YXoOhAQhKFTRLMYGdlbhc6T9A==", - "license": "MIT", - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.2", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "xstate": "^5.20.0" - }, - "peerDependenciesMeta": { - "xstate": { - "optional": true - } - } - }, "node_modules/a5-js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", @@ -15656,6 +15636,8 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -15980,7 +15962,9 @@ "version": "5.23.0", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.23.0.tgz", "integrity": "sha512-jo126xWXkU6ySQ91n51+H2xcgnMuZcCQpQoD3FQ79d32a6RQvryRh8rrDHnH4WDdN/yg5xNjlIRol9ispMvzeg==", + "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" @@ -16045,6 +16029,35 @@ "license": "MIT", "optional": true, "peer": true + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 380a2e05..0778c1e0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "@geoarrow/deck.gl-layers": "^0.4.0-beta.6", "@geoarrow/geoarrow-js": "^0.3.2", "@nextui-org/react": "^2.4.8", - "@xstate/react": "^6.0.0", "apache-arrow": "^21.1.0", "esbuild-sass-plugin": "^3.3.1", "framer-motion": "^12.23.19", @@ -25,7 +24,7 @@ "react-map-gl": "^8.1.0", "threads": "1.7.0", "uuid": "^13.0.0", - "xstate": "^5.22.0" + "zustand": "^5.0.2" }, "type": "module", "devDependencies": { diff --git a/src/index.tsx b/src/index.tsx index eeae6d29..3537c7d2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,13 +1,15 @@ import { createRender, useModel, useModelState } from "@anywidget/react"; import type { Initialize, Render } from "@anywidget/types"; import { MapViewState, PickingInfo } from "@deck.gl/core"; +import { PolygonLayer, PolygonLayerProps } from "@deck.gl/layers"; import { DeckGLRef } from "@deck.gl/react"; +import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; import type { IWidgetManager } from "@jupyter-widgets/base"; import { NextUIProvider } from "@nextui-org/react"; import debounce from "lodash.debounce"; import throttle from "lodash.throttle"; import * as React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { flyTo } from "./actions/fly-to.js"; @@ -27,13 +29,11 @@ import { OverlayRendererProps, } from "./renderers/types.js"; import SidePanel from "./sidepanel/index"; -import { useViewStateDebounced } from "./state"; +import { useStore, useViewStateDebounced } from "./state"; import Toolbar from "./toolbar.js"; import { getTooltip } from "./tooltip/index.js"; import { Message } from "./types.js"; import { isDefined, isGlobeView, sanitizeViewState } from "./util.js"; -import { MachineContext, MachineProvider } from "./xstate"; -import * as selectors from "./xstate/selectors"; import "maplibre-gl/dist/maplibre-gl.css"; import "./globals.css"; @@ -41,24 +41,71 @@ import "./globals.css"; await initParquetWasm(); function App() { - const actorRef = MachineContext.useActorRef(); - const isDrawingBBoxSelection = MachineContext.useSelector( - selectors.isDrawingBBoxSelection, - ); - const isOnMapHoverEventEnabled = MachineContext.useSelector( - selectors.isOnMapHoverEventEnabled, - ); + // ========================================================================= + // Client-Side State (Zustand) + // UI-only state that never syncs with Python + // See: src/state/store.ts + // ========================================================================= - const highlightedFeature = MachineContext.useSelector( - (s) => s.context.highlightedFeature, + // Feature highlighting + const highlightedFeature = useStore((state) => state.highlightedFeature); + const setHighlightedFeature = useStore( + (state) => state.setHighlightedFeature, ); - const bboxSelectPolygonLayer = MachineContext.useSelector( - selectors.getBboxSelectPolygonLayer, - ); - const bboxSelectBounds = MachineContext.useSelector( - selectors.getBboxSelectBounds, - ); + // Bounding box selection + const isDrawingBbox = useStore((state) => state.isDrawingBbox); + const bboxSelectStart = useStore((state) => state.bboxSelectStart); + const bboxSelectEnd = useStore((state) => state.bboxSelectEnd); + const setBboxStart = useStore((state) => state.setBboxStart); + const setBboxEnd = useStore((state) => state.setBboxEnd); + const setBboxHover = useStore((state) => state.setBboxHover); + + const isOnMapHoverEventEnabled = + isDrawingBbox && bboxSelectStart !== undefined; + + const bboxSelectBounds = useMemo(() => { + if (bboxSelectStart && bboxSelectEnd) { + const [x0, y0] = bboxSelectStart; + const [x1, y1] = bboxSelectEnd; + return [ + Math.min(x0, x1), + Math.min(y0, y1), + Math.max(x0, x1), + Math.max(y0, y1), + ]; + } + return null; + }, [bboxSelectStart, bboxSelectEnd]); + + const bboxSelectPolygonLayer = useMemo(() => { + if (bboxSelectStart && bboxSelectEnd) { + const bboxProps: PolygonLayerProps = { + id: "bbox-select-polygon", + data: [ + [ + [bboxSelectStart[0], bboxSelectStart[1]], + [bboxSelectEnd[0], bboxSelectStart[1]], + [bboxSelectEnd[0], bboxSelectEnd[1]], + [bboxSelectStart[0], bboxSelectEnd[1]], + ], + ], + getPolygon: (d) => d, + getFillColor: [0, 0, 0, 50], + getLineColor: [0, 0, 0, 130], + stroked: true, + getLineWidth: 2, + lineWidthUnits: "pixels", + }; + if (isDrawingBbox) { + bboxProps.getFillColor = [255, 255, 0, 120]; + bboxProps.getLineColor = [211, 211, 38, 200]; + bboxProps.getLineWidth = 2; + } + return new PolygonLayer(bboxProps); + } + return null; + }, [bboxSelectStart, bboxSelectEnd, isDrawingBbox]); const [justClicked, setJustClicked] = useState(false); @@ -73,15 +120,24 @@ function App() { const model = useModel(); + // ============================================================================ + // Python-Synced State (Backbone models) + // ============================================================================ + // State that bidirectionally syncs with Python via Jupyter widgets + // See: src/state/python-sync.ts + + // Map configuration const [basemapModelId] = useModelState("basemap"); const [mapHeight] = useModelState("height"); - const [showTooltip] = useModelState("show_tooltip"); - const [showSidePanel] = useModelState("show_side_panel"); - const [pickingRadius] = useModelState("picking_radius"); const [useDevicePixels] = useModelState( "use_device_pixels", ); const [parameters] = useModelState("parameters"); + + // UI settings + const [showTooltip] = useModelState("show_tooltip"); + const [showSidePanel] = useModelState("show_side_panel"); + const [pickingRadius] = useModelState("picking_radius"); const [customAttribution] = useModelState("custom_attribution"); const [mapId] = useState(uuidv4()); const [childLayerIds] = useModelState("layers"); @@ -138,7 +194,7 @@ function App() { model.widget_manager as IWidgetManager, updateStateCallback, bboxSelectBounds, - isDrawingBBoxSelection, + isDrawingBbox, setSelectedBounds, ); @@ -148,36 +204,52 @@ function App() { updateStateCallback, ); - const onMapClickHandler = useCallback((info: PickingInfo) => { - // We added this flag to prevent the hover event from firing after a - // click event. - if (typeof info.coordinate !== "undefined") { - if (model.get("_has_click_handlers")) { - model.send({ kind: "on-click", coordinate: info.coordinate }); + const onMapClickHandler = useCallback( + (info: PickingInfo) => { + // We added this flag to prevent the hover event from firing after a + // click event. + if (typeof info.coordinate !== "undefined") { + if (model.get("_has_click_handlers")) { + model.send({ kind: "on-click", coordinate: info.coordinate }); + } + } + setJustClicked(true); + + const clickedObject = info.object; + if (typeof clickedObject !== "undefined") { + setHighlightedFeature(info as GeoArrowPickingInfo); + } else { + setHighlightedFeature(undefined); } - } - setJustClicked(true); - actorRef.send({ - type: "Map click event", - data: info, - }); - setTimeout(() => { - setJustClicked(false); - }, 100); - }, []); + + if (isDrawingBbox && info.coordinate) { + if (bboxSelectStart === undefined) { + setBboxStart(info.coordinate); + } else { + setBboxEnd(info.coordinate); + } + } + + setTimeout(() => { + setJustClicked(false); + }, 100); + }, + [ + setHighlightedFeature, + isDrawingBbox, + bboxSelectStart, + setBboxStart, + setBboxEnd, + ], + ); const onMapHoverHandler = useCallback( - throttle( - (info: PickingInfo) => - isOnMapHoverEventEnabled && - !justClicked && - actorRef.send({ - type: "Map hover event", - data: info, - }), - 100, - ), - [isOnMapHoverEventEnabled, justClicked], + throttle((info: PickingInfo) => { + if (isOnMapHoverEventEnabled && !justClicked && info.coordinate) { + setBboxHover(info.coordinate); + } + }, 100), + [isOnMapHoverEventEnabled, justClicked, setBboxHover], ); const mapRenderProps: MapRendererProps = { @@ -189,7 +261,7 @@ function App() { ? layers.concat(bboxSelectPolygonLayer) : layers, getTooltip: (showTooltip && getTooltip) || undefined, - getCursor: () => (isDrawingBBoxSelection ? "crosshair" : "grab"), + getCursor: () => (isDrawingBbox ? "crosshair" : "grab"), pickingRadius: pickingRadius, onClick: onMapClickHandler, onHover: onMapHoverHandler, @@ -243,7 +315,7 @@ function App() { {showSidePanel && highlightedFeature && ( actorRef.send({ type: "Close side panel" })} + onClose={() => setHighlightedFeature(undefined)} /> )}
@@ -261,9 +333,7 @@ function App() { const WrappedApp = () => ( - - - + ); diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index 29e4263d..00000000 --- a/src/state.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useModel } from "@anywidget/react"; -import type { AnyModel } from "@anywidget/types"; -import debounce from "lodash.debounce"; -import * as React from "react"; - -const debouncedModelSaveChanges = debounce((model: AnyModel) => { - model.save_changes(); -}, 300); - -// TODO: add a `wait` parameter here, instead of having it hard-coded? -export function useViewStateDebounced(key: string): [T, (value: T) => void] { - const model = useModel(); - const [value, setValue] = React.useState(model.get(key)); - React.useEffect(() => { - const callback = () => { - setValue(model.get(key)); - }; - model.on(`change:${key}`, callback); - return () => model.off(`change:${key}`, callback); - }, [model, key]); - return [ - value, - (value) => { - model.set(key, value); - // Note: I think this has to be defined outside of this function so that - // you're calling debounce on the same function object? - debouncedModelSaveChanges(model); - }, - ]; -} diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 00000000..815dda22 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1,10 @@ +/** + * State management for Lonboard. + * + * Two types of state: + * - Client-only: UI state that never syncs with Python (Zustand) + * - Python-synced: State that bidirectionally syncs with Python (Backbone) + */ + +export { useStore } from "./store.js"; +export { useViewStateDebounced } from "./python-sync.js"; diff --git a/src/state/python-sync.ts b/src/state/python-sync.ts new file mode 100644 index 00000000..b4333f8c --- /dev/null +++ b/src/state/python-sync.ts @@ -0,0 +1,56 @@ +/** + * Python ↔ JavaScript state synchronization via Jupyter widgets. + * + * Provides hooks for state that requires bidirectional synchronization with Python + * through Backbone models. Use this for data that Python needs to know about. + */ +import { useModel } from "@anywidget/react"; +import type { AnyModel } from "@anywidget/types"; +import debounce from "lodash.debounce"; +import * as React from "react"; + +/** Debounce save operations to prevent WebSocket flooding (300ms delay) */ +const debouncedModelSaveChanges = debounce((model: AnyModel) => { + model.save_changes(); +}, 300); + +/** + * Bidirectional state sync with Python using debounced updates. + * + * Python → JS: Immediate updates when Python changes the value + * JS → Python: Debounced updates (300ms) to prevent flooding WebSocket + * + * @template T - Type of the state value + * @param key - Model attribute name to sync (e.g., "view_state") + * @returns [value, setValue] - Current value and debounced setter + * + * @example + * ```ts + * // Sync camera view state with Python + * const [viewState, setViewState] = useViewStateDebounced("view_state"); + * + * // Updates from Python appear immediately + * // Updates to Python are debounced + * setViewState({ longitude: -122.4, latitude: 37.8, zoom: 12 }); + * ``` + */ +export function useViewStateDebounced(key: string): [T, (value: T) => void] { + const model = useModel(); + const [value, setValue] = React.useState(model.get(key)); + + // Listen for Python → JS changes + React.useEffect(() => { + const callback = () => setValue(model.get(key)); + model.on(`change:${key}`, callback); + return () => model.off(`change:${key}`, callback); + }, [model, key]); + + // Return JS → Python debounced setter + return [ + value, + (newValue) => { + model.set(key, newValue); + debouncedModelSaveChanges(model); + }, + ]; +} diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 00000000..43170510 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,75 @@ +/** + * Client-side UI state management with Zustand. + * + * Manages ephemeral UI state that never syncs with Python. For Python-synced state, + * use Backbone models via `useViewStateDebounced()` from `./python-sync.ts`. + */ +import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; +import { create } from "zustand"; + +/** Shape of the client-side UI state. All properties are UI-only. */ +interface AppState { + /** Feature currently highlighted by hover or click interaction */ + highlightedFeature: GeoArrowPickingInfo | undefined; + /** Update the highlighted feature */ + setHighlightedFeature: (feature: GeoArrowPickingInfo | undefined) => void; + + /** Start coordinate for bounding box selection [lng, lat] */ + bboxSelectStart: number[] | undefined; + /** End coordinate for bounding box selection [lng, lat] */ + bboxSelectEnd: number[] | undefined; + /** Whether user is currently drawing a bounding box */ + isDrawingBbox: boolean; + + /** Actions for bounding box selection */ + startBboxSelection: () => void; + cancelBboxSelection: () => void; + clearBboxSelection: () => void; + setBboxStart: (coordinate: number[]) => void; + setBboxEnd: (coordinate: number[]) => void; + setBboxHover: (coordinate: number[]) => void; +} + +export const useStore = create((set) => ({ + // Feature highlighting + highlightedFeature: undefined, + setHighlightedFeature: (feature) => set({ highlightedFeature: feature }), + + // Bounding box selection state + bboxSelectStart: undefined, + bboxSelectEnd: undefined, + isDrawingBbox: false, + + // Bounding box actions + startBboxSelection: () => + set({ + isDrawingBbox: true, + bboxSelectStart: undefined, + bboxSelectEnd: undefined, + }), + cancelBboxSelection: () => + set({ + isDrawingBbox: false, + bboxSelectStart: undefined, + bboxSelectEnd: undefined, + }), + clearBboxSelection: () => + set({ bboxSelectStart: undefined, bboxSelectEnd: undefined }), + setBboxStart: (coordinate) => set({ bboxSelectStart: coordinate }), + setBboxEnd: (coordinate) => + set((state) => { + // Complete selection only if we have a start point + if (state.bboxSelectStart) { + return { bboxSelectEnd: coordinate, isDrawingBbox: false }; + } + return {}; + }), + setBboxHover: (coordinate) => + set((state) => { + // Show preview while drawing + if (state.isDrawingBbox && state.bboxSelectStart) { + return { bboxSelectEnd: coordinate }; + } + return {}; + }), +})); diff --git a/src/toolbar.tsx b/src/toolbar.tsx index cd57bfe1..e904ea54 100644 --- a/src/toolbar.tsx +++ b/src/toolbar.tsx @@ -2,25 +2,25 @@ import { Button, ButtonGroup, Tooltip } from "@nextui-org/react"; import React from "react"; import { SquareIcon, XMarkIcon } from "./icons"; -import { MachineContext } from "./xstate"; -import * as selectors from "./xstate/selectors"; +import { useStore } from "./state"; const Toolbar: React.FC = () => { - const actorRef = MachineContext.useActorRef(); - const isDrawingBBoxSelection = MachineContext.useSelector( - selectors.isDrawingBBoxSelection, - ); - const isBboxDefined = MachineContext.useSelector( - (state) => state.context.bboxSelectStart && state.context.bboxSelectEnd, - ); + const isDrawingBbox = useStore((state) => state.isDrawingBbox); + const bboxSelectStart = useStore((state) => state.bboxSelectStart); + const bboxSelectEnd = useStore((state) => state.bboxSelectEnd); + const startBboxSelection = useStore((state) => state.startBboxSelection); + const cancelBboxSelection = useStore((state) => state.cancelBboxSelection); + const clearBboxSelection = useStore((state) => state.clearBboxSelection); + + const isBboxDefined = bboxSelectStart && bboxSelectEnd; const handleButtonClick = () => { - if (isDrawingBBoxSelection) { - actorRef.send({ type: "Cancel BBox draw" }); + if (isDrawingBbox) { + cancelBboxSelection(); } else if (isBboxDefined) { - actorRef.send({ type: "Clear BBox" }); + clearBboxSelection(); } else { - actorRef.send({ type: "BBox select button clicked" }); + startBboxSelection(); } }; @@ -44,7 +44,7 @@ const Toolbar: React.FC = () => { { diff --git a/src/xstate/index.tsx b/src/xstate/index.tsx deleted file mode 100644 index 91374186..00000000 --- a/src/xstate/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createBrowserInspector } from "@statelyai/inspect"; -import { createActorContext } from "@xstate/react"; -import React from "react"; - -import { machine } from "./machine"; - -export const MachineContext = createActorContext( - machine, - process.env.XSTATE_INSPECT === "true" - ? { - inspect: createBrowserInspector().inspect, - } - : undefined, -); - -export function MachineProvider(props: { children: React.ReactNode }) { - return {props.children}; -} diff --git a/src/xstate/machine.ts b/src/xstate/machine.ts deleted file mode 100644 index bf4f1737..00000000 --- a/src/xstate/machine.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { PickingInfo } from "@deck.gl/core"; -import { GeoArrowPickingInfo } from "@geoarrow/deck.gl-layers"; -import { assign, createMachine } from "xstate"; - -export const machine = createMachine( - { - /** @xstate-layout N4IgpgJg5mDOIC5QBsD2A7ARqghgJwgDoAFHdAAgFtUIwBiAIQdQA9zYxkwBjAF3MwBXXrwzluyAJbcA1pADaABgC6iUAAdUsSb0kY1IFogC0AJgAcATkKKAbABZzARnMBWe09e3Lt1wBoQAE9EJwB2UJsAZlDXS1DIxVdQuMVTAF80gLQsXAISMioaegBhLnxyJlYlVSQQTW1dfVqjBHNTQlMwxUdTZMtUtwDghGNbQic4z1CnSO97JMcMrIxsfCJSCmpaOgBZHHVxKVlyMAA3MHReaoN6nT10AxbjJ1Mx0PtIyPtLT98Z0yGJl69hsL1moUU5g+tjsSxA2VWeQ2hW2pS0YHYklo5HUZE411qt0aD2aJlckSc43spm+lnMthp0UigJGpgphCsrk8-Wm5I+cIRuSIAGVODxdOgoAJsGxYLx8PwifddvtDtIZCdzpcCRotHcmqAnqYkoRpmaXuZFJF+v4gohrZEOYorcbLJZufT7AKVkLCKKuHxJJLpax2PK8Iq9cS6MUyNxOBVmGwIHgcAB3HV1KP3R4mRwgz6dOwfJy2eLmFmmXqEVyuTpQiwMun8zLwn1rP1iwPBzAyk7oCA47MYFUHAAWqHOeE1FyuKhuw5JhpC7WdsRpTns8VLENtwx6hEibNCbOp9lS9l83pyHf94qDUt7oYug6VI72Bwk6pn2vnhMXuYIGEYzRDEVg+NEbi2JWDiEPY3zmEesSQnS16IiKXYSo+fYvkODTKrG6Dxsgiahim6aZm+S6GGSiGED4oRlueF7uJYLLuBEVZgakvzOqEaG+ne3bYaGcoKnh+roDGaAcJi2K4ug+J-rq+EGjRrLmByrjmOE9KmIoLihIMdoIJEtY1nxFJOJusywq2gq3phD4hmwuFUdJ6JyRiClKTUKmSYBzzaRy5YxJe5LRPMLKMdYsxQjpfFbuYGStugRTwLUDkEAuqnUU8tjsgxTHOjSrEssYsyUhMEHJC8SWuAJHbIlsYA5QFpKstWnhWHY7zdEkMTlXS4yeN0tgMjpnQTI1eRCVhLlhuJVFtcSgWmJYrg1vMHjuNVXJsSZCScZ4ZYzDpXi2DNGEBvNT6uQOEmrf+uVre69HFVYHyKG6lj2CytguIeP1cvE2kxJdKVAA */ - id: "lonboard", - - types: { - context: {} as { - bboxSelectStart: number[] | undefined; - bboxSelectEnd: number[] | undefined; - highlightedFeature: GeoArrowPickingInfo | undefined; - }, - events: {} as - | { - type: "BBox select button clicked"; - } - | { - type: "Map click event"; - data: PickingInfo; - } - | { - type: "Map hover event"; - data: PickingInfo; - } - | { - type: "Clear BBox"; - } - | { - type: "Cancel BBox draw"; - } - | { - type: "Close side panel"; - }, - actions: {} as - | { - type: "setBboxSelectStart"; - } - | { - type: "setBboxSelectEnd"; - } - | { - type: "setHighlightedFeature"; - } - | { - type: "clearBboxSelect"; - } - | { - type: "clearHighlightedFeature"; - }, - }, - - context: { - bboxSelectStart: undefined, - bboxSelectEnd: undefined, - highlightedFeature: undefined, - }, - - states: { - "Pan mode": { - on: { - "BBox select button clicked": { - target: "Selecting bbox start position", - actions: "clearBboxSelect", - }, - - "Clear BBox": { - target: "Pan mode", - actions: "clearBboxSelect", - }, - - "Map click event": { - target: "Pan mode", - actions: "setHighlightedFeature", - }, - - "Close side panel": { - target: "Pan mode", - actions: "clearHighlightedFeature", - }, - }, - }, - - "Selecting bbox start position": { - on: { - "Map click event": { - target: "Selecting bbox end position", - actions: "setBboxSelectStart", - }, - - "Cancel BBox draw": { - target: "Pan mode", - reenter: true, - actions: "clearBboxSelect", - }, - - "Close side panel": { - target: "Selecting bbox start position", - actions: "clearHighlightedFeature", - }, - }, - }, - - "Selecting bbox end position": { - on: { - "Map hover event": { - target: "Selecting bbox end position", - actions: "setBboxSelectEnd", - }, - - "Map click event": { - target: "Pan mode", - actions: "setBboxSelectEnd", - }, - - "Cancel BBox draw": { - target: "Pan mode", - reenter: true, - actions: "clearBboxSelect", - }, - - "Close side panel": { - target: "Selecting bbox end position", - actions: "clearHighlightedFeature", - }, - }, - }, - }, - initial: "Pan mode", - }, - { - actions: { - clearBboxSelect: assign(() => { - return { - bboxSelectStart: undefined, - bboxSelectEnd: undefined, - }; - }), - setBboxSelectStart: assign(({ event }) => { - if (event.type === "Map click event" && "data" in event) { - return { - bboxSelectStart: event.data.coordinate, - }; - } - return {}; - }), - setBboxSelectEnd: assign(({ event }) => { - if ( - (event.type === "Map click event" || - event.type === "Map hover event") && - "data" in event - ) { - return { - bboxSelectEnd: event.data.coordinate, - }; - } - return {}; - }), - setHighlightedFeature: assign(({ event }) => { - if (event.type === "Map click event" && "data" in event) { - const clickedObject = event.data.object; - - if (typeof clickedObject !== "undefined") { - return { - highlightedFeature: event.data, - }; - } - } - return { highlightedFeature: undefined }; - }), - clearHighlightedFeature: assign(() => { - return { highlightedFeature: undefined }; - }), - }, - - actors: {}, - }, -); diff --git a/src/xstate/selectors.ts b/src/xstate/selectors.ts deleted file mode 100644 index 3dbfb768..00000000 --- a/src/xstate/selectors.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PolygonLayer, PolygonLayerProps } from "@deck.gl/layers"; -import { type SnapshotFrom } from "xstate"; - -import type { machine } from "./machine"; - -type Snapshot = SnapshotFrom; - -export const isDrawingBBoxSelection = (state: Snapshot) => - state.matches("Selecting bbox start position") || - state.matches("Selecting bbox end position"); - -export const isOnMapHoverEventEnabled = (state: Snapshot) => - state.matches("Selecting bbox end position"); - -export const isTooltipEnabled = (state: Snapshot) => state.matches("Pan mode"); - -export const getButtonLabel = (state: Snapshot) => { - if (state.matches("Selecting bbox start position")) { - return "Click the map to start drawing the selection box"; - } else if (state.matches("Selecting bbox end position")) { - return "Click the map to finish drawing the selection box"; - } - return "Click here to start selecting"; -}; - -export const getBboxSelectPolygonLayer = (state: Snapshot) => { - if (state.context.bboxSelectStart && state.context.bboxSelectEnd) { - const bboxProps: PolygonLayerProps = { - id: "bbox-select-polygon", - data: [ - [ - [state.context.bboxSelectStart[0], state.context.bboxSelectStart[1]], - [state.context.bboxSelectEnd[0], state.context.bboxSelectStart[1]], - [state.context.bboxSelectEnd[0], state.context.bboxSelectEnd[1]], - [state.context.bboxSelectStart[0], state.context.bboxSelectEnd[1]], - ], - ], - getPolygon: (d) => d, - getFillColor: [0, 0, 0, 50], - getLineColor: [0, 0, 0, 130], - stroked: true, - getLineWidth: 2, - lineWidthUnits: "pixels", - }; - if (isDrawingBBoxSelection(state)) { - bboxProps.getFillColor = [255, 255, 0, 120]; - bboxProps.getLineColor = [211, 211, 38, 200]; - bboxProps.getLineWidth = 2; - } - return new PolygonLayer(bboxProps); - } - return null; -}; - -export const getBboxSelectBounds = (state: Snapshot) => { - if (state.context.bboxSelectStart && state.context.bboxSelectEnd) { - const [x0, y0] = state.context.bboxSelectStart; - const [x1, y1] = state.context.bboxSelectEnd; - return [ - Math.min(x0, x1), - Math.min(y0, y1), - Math.max(x0, x1), - Math.max(y0, y1), - ]; - } - return null; -}; diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 00000000..cd5aae18 --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,67 @@ +"""Shared fixtures for UI tests.""" + +import sys +from pathlib import Path + +import geopandas as gpd +import pytest +from shapely.geometry import Point + +from lonboard import Map +from lonboard.layer import ScatterplotLayer + +# Add project root and tests/ui directories to path +project_root = Path(__file__).parent.parent.parent +tests_ui_dir = Path(__file__).parent +sys.path.insert(0, str(project_root)) +sys.path.insert(0, str(tests_ui_dir)) + + +@pytest.fixture +def sample_geodataframe(): + """Simple GeoDataFrame for testing.""" + return gpd.GeoDataFrame( + {"name": ["test_point"], "value": [1]}, + geometry=[Point(0, 0)], + crs="EPSG:4326", + ) + + +@pytest.fixture +def sample_layer(sample_geodataframe): + """Scatterplot layer for testing.""" + return ScatterplotLayer.from_geopandas( + sample_geodataframe, + get_fill_color=[255, 0, 0], + get_radius=5000, + ) + + +@pytest.fixture +def sample_map(sample_layer): + """Lonboard map for testing.""" + return Map(sample_layer) + + +@pytest.fixture +def sample_map_with_side_panel(sample_layer): + """Lonboard map with side panel enabled for testing.""" + return Map(sample_layer, show_side_panel=True) + + +@pytest.fixture +def sample_map_with_tooltip(sample_layer): + """Lonboard map with tooltip enabled for testing.""" + return Map(sample_layer, show_tooltip=True, show_side_panel=False) + + +@pytest.fixture +def sample_map_with_tooltip_and_panel(sample_layer): + """Lonboard map with both tooltip and side panel enabled for testing.""" + return Map(sample_layer, show_tooltip=True, show_side_panel=True) + + +@pytest.fixture +def sample_map_with_no_tooltip_or_panel(sample_layer): + """Lonboard map with both tooltip and side panel disabled for testing.""" + return Map(sample_layer, show_tooltip=False, show_side_panel=False) diff --git a/tests/ui/test_bbox_interaction.py b/tests/ui/test_bbox_interaction.py index b5bad67c..ce4bc1b1 100644 --- a/tests/ui/test_bbox_interaction.py +++ b/tests/ui/test_bbox_interaction.py @@ -1,87 +1,14 @@ """Bounding box interaction tests for Lonboard UI.""" -import re -import sys -from pathlib import Path - -import geopandas as gpd import pytest from IPython.display import display -from shapely.geometry import Point - -from lonboard import Map -from lonboard.layer import ScatterplotLayer - -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - - -@pytest.fixture -def sample_geodataframe(): - """Simple GeoDataFrame for testing.""" - return gpd.GeoDataFrame( - {"name": ["test_point"], "value": [1]}, - geometry=[Point(0, 0)], - crs="EPSG:4326", - ) - - -@pytest.fixture -def sample_layer(sample_geodataframe): - """Scatterplot layer for testing.""" - return ScatterplotLayer.from_geopandas( - sample_geodataframe, - get_fill_color=[255, 0, 0], - get_radius=5000, - ) - - -@pytest.fixture -def sample_map(sample_layer): - """Lonboard map for testing.""" - return Map(sample_layer) - - -def validate_geographic_data(data_text: str | None): - """Validate geographic data in format: 'Selected bounds: (minLon, minLat, maxLon, maxLat)'.""" - assert data_text, "Expected data text to not be None" - assert "None" not in data_text, "Expected output to contain coordinates" - assert "(0, 0, 0, 0)" not in data_text, "Expected output to not be zeros" - - data_match = re.search( - r"Selected bounds: \(([-\deE.]+), ([-\deE.]+), ([-\deE.]+), ([-\deE.]+)\)", - data_text, - ) - assert data_match, f"Expected to find data pattern in: {data_text}" - - if data_match: - min_lon, min_lat, max_lon, max_lat = map(float, data_match.groups()) - - assert min_lon != 0, "Coordinates should not be zero" - assert min_lat != 0, "Coordinates should not be zero" - assert max_lon != 0, "Coordinates should not be zero" - assert max_lat != 0, "Coordinates should not be zero" - - assert min_lon < max_lon, "Min values should be less than max values" - assert min_lat < max_lat, "Min values should be less than max values" - - assert -180 <= min_lon <= 180, "Longitude should be in range [-180, 180]" - assert -180 <= max_lon <= 180, "Longitude should be in range [-180, 180]" - - assert -90 <= min_lat <= 90, "Latitude should be in range [-90, 90]" - assert -90 <= max_lat <= 90, "Latitude should be in range [-90, 90]" - - -def draw_on_canvas(page_session, start_pos, end_pos): - """Draw on canvas using canvas-relative coordinates.""" - canvas = page_session.locator("canvas").first - - canvas.click(position=start_pos) - page_session.wait_for_timeout(300) - canvas.hover(position=end_pos) - page_session.wait_for_timeout(300) - canvas.click(position=end_pos) - page_session.wait_for_timeout(500) +from test_utils import ( + TestConstants, + draw_bbox_on_canvas, + start_bbox_selection, + validate_geographic_data, + wait_for_canvas, +) @pytest.mark.usefixtures("solara_test") @@ -94,8 +21,7 @@ def test_bbox_interaction_workflow(page_session, sample_map): # Display and wait for widget to render display(sample_map) - canvas = page_session.locator("canvas").first - canvas.wait_for(timeout=30000) + canvas = wait_for_canvas(page_session) # Verify UI components are present bbox_button = page_session.locator('button[aria-label*="Select"]') @@ -103,17 +29,14 @@ def test_bbox_interaction_workflow(page_session, sample_map): assert bbox_button.count() > 0, "BBox button should be present" # Start bbox interaction - bbox_button.wait_for(timeout=10000) - bbox_button.click() + start_bbox_selection(page_session) # Verify interaction mode is active active_button = page_session.locator('button[aria-label*="Cancel"]') - active_button.wait_for(timeout=5000) + active_button.wait_for(timeout=TestConstants.TIMEOUT_BUTTON_STATE) # Perform bbox selection using canvas-relative coordinates - start_pos = {"x": 25, "y": 25} - end_pos = {"x": 75, "y": 75} - draw_on_canvas(page_session, start_pos, end_pos) + draw_bbox_on_canvas(page_session) # Verify bbox coordinates changed and are valid interaction_result = sample_map.selected_bounds diff --git a/tests/ui/test_bbox_selection.py b/tests/ui/test_bbox_selection.py new file mode 100644 index 00000000..5ffb5c86 --- /dev/null +++ b/tests/ui/test_bbox_selection.py @@ -0,0 +1,142 @@ +"""Bbox selection tests.""" + +import pytest +from test_utils import ( + TestConstants, + draw_bbox_on_canvas, + setup_map_widget, + start_bbox_selection, + validate_geographic_bounds, + verify_bbox_cleared, + wait_for_button, +) + + +@pytest.mark.usefixtures("solara_test") +class TestBboxSelectionWorkflow: + """Test bbox selection workflow.""" + + def test_complete_bbox_workflow(self, page_session, sample_map): + setup_map_widget(page_session, sample_map) + + # Initial state + select_button = wait_for_button( + page_session, + "select", + TestConstants.TIMEOUT_BUTTON_CLICK, + ) + assert select_button.count() > 0 + + # Start selection + start_bbox_selection(page_session) + cancel_button = wait_for_button(page_session, "cancel") + assert cancel_button.count() > 0 + + # Complete selection + draw_bbox_on_canvas(page_session) + + # Verify results + bounds = sample_map.selected_bounds + assert bounds is not None + assert validate_geographic_bounds(bounds) + + # Final state + clear_button = wait_for_button(page_session, "clear") + assert clear_button.count() > 0 + + def test_bbox_cancel_after_start_point(self, page_session, sample_map): + canvas = setup_map_widget(page_session, sample_map) + + start_bbox_selection(page_session) + cancel_button = wait_for_button(page_session, "cancel") + assert cancel_button.count() > 0 + + # Set start point + canvas.click(position=TestConstants.CANVAS_START_POS) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Cancel selection + cancel_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Verify reset to initial state + select_button = wait_for_button(page_session, "select") + assert select_button.count() > 0 + assert verify_bbox_cleared(sample_map) + + def test_bbox_clear_after_completion(self, page_session, sample_map): + setup_map_widget(page_session, sample_map) + + start_bbox_selection(page_session) + draw_bbox_on_canvas(page_session) + + # Verify completion + assert sample_map.selected_bounds is not None + assert validate_geographic_bounds(sample_map.selected_bounds) + + # Clear selection + clear_button = wait_for_button(page_session, "clear") + assert clear_button.count() > 0 + clear_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Verify reset + assert verify_bbox_cleared(sample_map) + select_button = wait_for_button(page_session, "select") + assert select_button.count() > 0 + + def test_bbox_hover_updates(self, page_session, sample_map): + canvas = setup_map_widget(page_session, sample_map) + start_bbox_selection(page_session) + + # Set start point + canvas.click(position=TestConstants.CANVAS_START_POS) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Test hover updates + canvas.hover(position={"x": 50, "y": 50}) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + canvas.hover(position=TestConstants.CANVAS_END_POS) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Complete selection + canvas.click(position=TestConstants.CANVAS_END_POS) + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Verify bounds + bounds = sample_map.selected_bounds + assert bounds is not None + assert bounds[0] < bounds[2] + assert bounds[1] < bounds[3] + + +@pytest.mark.usefixtures("solara_test") +class TestToolbarStates: + """Test toolbar button states.""" + + def test_button_state_transitions(self, page_session, sample_map): + setup_map_widget(page_session, sample_map) + + # Initial state + select_button = wait_for_button( + page_session, + "select", + TestConstants.TIMEOUT_BUTTON_CLICK, + ) + assert select_button.count() > 0 + + # Selection mode + start_bbox_selection(page_session) + cancel_button = wait_for_button(page_session, "cancel") + assert cancel_button.count() > 0 + + # Completed state + draw_bbox_on_canvas(page_session) + clear_button = wait_for_button(page_session, "clear") + assert clear_button.count() > 0 + + # Reset state + clear_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + select_button = wait_for_button(page_session, "select") + assert select_button.count() > 0 diff --git a/tests/ui/test_feature_interaction.py b/tests/ui/test_feature_interaction.py new file mode 100644 index 00000000..f0da3a5e --- /dev/null +++ b/tests/ui/test_feature_interaction.py @@ -0,0 +1,676 @@ +""" +Feature Interaction Tests for Lonboard UI + +This module tests the complete feature interaction system, which includes: +- Hover interactions (tooltips and highlighting) +- Click interactions (side panel display) +- Mixed interaction patterns +- Configuration and property validation +- Integration with other UI modes (bbox selection) + +The feature interaction system allows users to explore geographic data through: +1. **Tooltips**: Hover-based information display (when show_tooltip=True) +2. **Side Panel**: Click-based detailed information display (when show_side_panel=True) +3. **Feature Highlighting**: Visual feedback on hover/click regardless of display mode + +Both tooltip and side panel present the same underlying feature data, just in different formats. +""" + +import sys +from pathlib import Path + +import geopandas as gpd +import pytest +from IPython.display import display +from shapely.geometry import Point +from test_utils import ( + TestConstants, + check_tooltip_visibility, + click_feature_position, + get_tooltip_content, + get_tooltip_position, + hover_feature_position, + setup_map_widget, + start_bbox_selection, + wait_for_button, +) + +from lonboard import Map +from lonboard.layer import ScatterplotLayer + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +# ============================================================================= +# FIXTURES +# ============================================================================= + + +@pytest.fixture +def feature_test_geodataframe(): + """ + GeoDataFrame with rich data for feature interaction testing. + + Contains multiple data types (text, numbers, categories) to ensure + the feature interaction system handles various data formats correctly. + """ + return gpd.GeoDataFrame( + { + "name": ["test_point_1", "test_point_2", "test_point_3"], + "value": [100, 200, 300], + "category": ["A", "B", "C"], + "description": [ + "First test point", + "Second test point", + "Third test point", + ], + "coordinates": [(0, 0), (1, 1), (-1, -1)], + "priority": [1, 2, 3], + }, + geometry=[Point(0, 0), Point(1, 1), Point(-1, -1)], + crs="EPSG:4326", + ) + + +@pytest.fixture +def feature_test_layer(feature_test_geodataframe): + """ + Scatterplot layer configured for comprehensive feature interaction testing. + + The layer is explicitly configured to be pickable and has sufficient + radius to ensure reliable hover and click interactions during testing. + """ + return ScatterplotLayer.from_geopandas( + feature_test_geodataframe, + get_fill_color=[255, 0, 0], + get_radius=5000, + pickable=True, # Essential for hover and click interactions + ) + + +# ============================================================================= +# VALIDATION UTILITIES +# ============================================================================= + + +def validate_feature_configuration( + map_obj, + expected_show_tooltip, + expected_show_side_panel, +): + """ + Validate that map has correct feature interaction configuration. + + Args: + map_obj: Map instance to validate + expected_show_tooltip: Expected show_tooltip value + expected_show_side_panel: Expected show_side_panel value + """ + assert hasattr(map_obj, "show_tooltip"), "Map should have show_tooltip attribute" + assert hasattr(map_obj, "show_side_panel"), ( + "Map should have show_side_panel attribute" + ) + + assert map_obj.show_tooltip == expected_show_tooltip, ( + f"Expected show_tooltip={expected_show_tooltip}, got {map_obj.show_tooltip}" + ) + assert map_obj.show_side_panel == expected_show_side_panel, ( + f"Expected show_side_panel={expected_show_side_panel}, got {map_obj.show_side_panel}" + ) + + +def validate_map_display(map_obj): + """ + Validate that map can be displayed without errors. + + This is important because feature interactions only work on properly + rendered maps. + """ + try: + display(map_obj) + except (OSError, ValueError, RuntimeError) as e: + pytest.fail(f"Map display failed with error: {e}") + else: + return True + + +# ============================================================================= +# PYTHON PROPERTY TESTS +# ============================================================================= + + +class TestFeatureInteractionConfiguration: + """ + Test the Python properties that control feature interaction behavior. + + These tests focus on the configuration aspects without requiring + browser interaction, making them fast and suitable for CI/CD. + + Configuration Options: + - show_tooltip: Enables hover-based tooltip display + - show_side_panel: Enables click-based side panel display + - Both can be enabled simultaneously or independently + """ + + def test_tooltip_enabled_configuration(self, sample_map_with_tooltip): + """ + Test that show_tooltip=True properly configures tooltip interactions. + + Verifies that the map can be created with tooltips enabled and + displays without errors. + """ + validate_feature_configuration( + sample_map_with_tooltip, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(sample_map_with_tooltip) + + def test_tooltip_disabled_configuration(self, sample_map_with_no_tooltip_or_panel): + """ + Test that show_tooltip=False disables tooltip interactions. + + Ensures that maps can be created with tooltips explicitly disabled + and that the configuration persists correctly. + """ + validate_feature_configuration( + sample_map_with_no_tooltip_or_panel, + expected_show_tooltip=False, + expected_show_side_panel=False, + ) + validate_map_display(sample_map_with_no_tooltip_or_panel) + + def test_side_panel_enabled_configuration(self, sample_map_with_side_panel): + """ + Test that show_side_panel=True enables click-based interactions. + + Validates that the side panel configuration works correctly + for feature selection on click. + """ + validate_feature_configuration( + sample_map_with_side_panel, + expected_show_tooltip=False, + expected_show_side_panel=True, + ) + validate_map_display(sample_map_with_side_panel) + + def test_both_enabled_configuration(self, sample_map_with_tooltip_and_panel): + """ + Test that both tooltip and side panel can work simultaneously. + + This is an important test as it ensures that users can have + both hover tooltips and click side panels enabled at the same time. + """ + validate_feature_configuration( + sample_map_with_tooltip_and_panel, + expected_show_tooltip=True, + expected_show_side_panel=True, + ) + validate_map_display(sample_map_with_tooltip_and_panel) + + def test_default_configuration(self, feature_test_layer): + """ + Test default behavior when no explicit configuration is provided. + + Lonboard should default to no tooltips but side panel enabled, + providing the traditional click-to-explore behavior. + """ + default_map = Map(feature_test_layer, height="400px") + validate_feature_configuration( + default_map, + expected_show_tooltip=False, + expected_show_side_panel=True, + ) + + def test_configuration_persistence(self, feature_test_layer): + """ + Test that configuration persists after map operations. + + Ensures that the feature interaction settings remain consistent + after the map is displayed and potentially manipulated. + """ + map_obj = Map( + feature_test_layer, + show_tooltip=True, + show_side_panel=False, + height="400px", + ) + + # Verify initial state + assert map_obj.show_tooltip is True + assert map_obj.show_side_panel is False + + # Display map (simulates user interaction) + validate_map_display(map_obj) + + # Verify properties are still correct after display + assert map_obj.show_tooltip is True + assert map_obj.show_side_panel is False + + def test_configuration_with_different_layers(self, feature_test_geodataframe): + """ + Test that configuration works consistently across different layer types. + + Ensures that feature interaction configuration is not dependent + on specific layer properties or styling. + """ + configs = [ + {"get_fill_color": [255, 0, 0], "get_radius": 5000}, + {"get_fill_color": [0, 255, 0], "get_radius": 3000}, + {"get_fill_color": [0, 0, 255], "get_radius": 7000}, + ] + + for config in configs: + layer = ScatterplotLayer.from_geopandas(feature_test_geodataframe, **config) + map_obj = Map( + layer, + show_tooltip=True, + show_side_panel=False, + height="300px", + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(map_obj) + + @pytest.mark.usefixtures("feature_test_layer") + def test_configuration_edge_cases(self): + """ + Test edge cases and potential error conditions in configuration. + + Ensures that the system handles unusual configurations gracefully + and maintains expected behavior. + """ + # Test with various GeoDataFrame structures + test_cases = [ + # Single point + gpd.GeoDataFrame( + {"single_value": [42]}, + geometry=[Point(0, 0)], + crs="EPSG:4326", + ), + # Multiple data types + gpd.GeoDataFrame( + { + "text": ["A", "B"], + "number": [1.5, 2.7], + "integer": [10, 20], + "boolean": [True, False], + }, + geometry=[Point(0, 0), Point(1, 1)], + crs="EPSG:4326", + ), + # Minimal properties + gpd.GeoDataFrame( + geometry=[Point(0, 0)], + crs="EPSG:4326", + ), + ] + + for gdf in test_cases: + layer = ScatterplotLayer.from_geopandas( + gdf, + get_fill_color=[255, 0, 0], + get_radius=5000, + ) + map_obj = Map( + layer, + show_tooltip=True, + show_side_panel=False, + height="300px", + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + validate_map_display(map_obj) + + +# ============================================================================= +# BROWSER INTERACTION TESTS +# ============================================================================= + + +@pytest.mark.usefixtures("solara_test") +class TestFeatureInteractionBehavior: + """ + Test actual user interactions with features in a browser environment. + + These tests use Playwright to simulate real user interactions including + hover events, clicks, and mixed interaction patterns. They verify that + the feature interaction system responds correctly to user input. + + Interaction Types Tested: + - Hover: Triggers tooltips and visual highlighting + - Click: Triggers side panel display and feature selection + - Mixed: Ensures hover and click interactions work together + """ + + def test_tooltip_hover_behavior(self, page_session, sample_map_with_tooltip): + """ + Test tooltip appearance and behavior on hover when show_tooltip=True. + + This test verifies that: + - Tooltips appear when hovering over features + - Tooltips disappear when hovering over empty areas + - Tooltip content and positioning are reasonable when visible + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Hover over center position (should have features) + hover_feature_position(page_session, "center") + + # Check if tooltip appears + tooltip_visible = check_tooltip_visibility(page_session, should_be_visible=True) + + # Tooltip might not appear if no feature is at the exact position + # The important thing is that no errors occur and the system responds to hover + if tooltip_visible: + # Verify tooltip content and positioning when visible + content = get_tooltip_content(page_session) + position = get_tooltip_position(page_session) + + assert content is not None, "Tooltip should have content when visible" + assert position is not None, "Tooltip should have position when visible" + assert position["width"] > 0, "Tooltip should have width" + assert position["height"] > 0, "Tooltip should have height" + + # Hover over empty area (should not show tooltip) + hover_feature_position(page_session, "empty") + + # Tooltip should disappear or remain hidden + check_tooltip_visibility(page_session, should_be_visible=False) + + assert canvas.count() > 0, "Map canvas should remain loaded" + + def test_no_tooltip_when_disabled( + self, + page_session, + sample_map_with_no_tooltip_or_panel, + ): + """ + Test that tooltips never appear when show_tooltip=False. + + This ensures that the tooltip system is properly disabled + and doesn't interfere with other interactions. + """ + canvas = setup_map_widget(page_session, sample_map_with_no_tooltip_or_panel) + + # Hover over different positions + for position in ["center", "start", "end"]: + hover_feature_position(page_session, position) + + # Tooltip should never be visible when disabled + tooltip_hidden = check_tooltip_visibility( + page_session, + should_be_visible=False, + ) + assert tooltip_hidden, ( + f"Tooltip should not be visible when show_tooltip=False (hovered at {position})" + ) + + assert canvas.count() > 0, "Map canvas should remain loaded" + + def test_side_panel_click_behavior(self, page_session, sample_map_with_side_panel): + """ + Test side panel behavior when show_side_panel=True. + + Verifies that clicking on features properly triggers side panel + display and that the system remains stable. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Click on different positions + for position in ["center", "start", "end"]: + click_feature_position(page_session, position) + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # System should handle clicks gracefully + assert canvas.count() > 0, ( + f"Map canvas should remain stable after click at {position}" + ) + + def test_mixed_hover_and_click_interactions( + self, + page_session, + sample_map_with_tooltip_and_panel, + ): + """ + Test behavior when both tooltip and side panel are enabled. + + This is a critical test that ensures hover tooltips and click + side panels can coexist without interfering with each other. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip_and_panel) + + # Test hover first + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Then test click + click_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Test hover again after click + hover_feature_position(page_session, "empty") + + # System should remain stable after both hover and click interactions + assert canvas.count() > 0, ( + "Map canvas should remain loaded after mixed interactions" + ) + + def test_hover_before_click_stability( + self, + page_session, + sample_map_with_side_panel, + ): + """ + Test that hovering before clicking doesn't interfere with feature selection. + + Ensures that hover state doesn't affect subsequent click behavior, + which is important for smooth user experience. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Hover first, then click + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + click_feature_position(page_session, "center") + assert canvas.count() > 0 + + def test_multiple_interaction_patterns(self, page_session, sample_map_with_tooltip): + """ + Test complex patterns of hover and click interactions. + + Simulates realistic user behavior with rapid interaction changes + to ensure the system remains stable and responsive. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Test pattern: hover -> hover -> click -> hover -> hover + interactions = [ + ("hover", "center"), + ("hover", "start"), + ("click", "center"), + ("hover", "empty"), + ("hover", "end"), + ] + + for action, position in interactions: + if action == "hover": + hover_feature_position(page_session, position) + else: + click_feature_position(page_session, position) + + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + assert canvas.count() > 0, ( + f"Map canvas should remain stable after {action} at {position}" + ) + + def test_feature_interaction_with_bbox_mode( + self, + page_session, + sample_map_with_side_panel, + ): + """ + Test feature interactions during bbox selection mode. + + Ensures that feature highlighting and selection work correctly + even when the map is in a different interaction mode. + """ + canvas = setup_map_widget(page_session, sample_map_with_side_panel) + + # Start bbox mode + start_bbox_selection(page_session) + cancel_button = wait_for_button(page_session, "cancel") + assert cancel_button.count() > 0 + + # Test hover during bbox mode + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + # Test click during bbox mode + click_feature_position(page_session, "center") + assert canvas.count() > 0 + + # Cancel bbox mode + cancel_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + # Verify return to normal feature interaction mode + select_button = wait_for_button(page_session, "select") + assert select_button.count() > 0 + + def test_tooltip_positioning_and_styling( + self, + page_session, + sample_map_with_tooltip, + ): + """ + Test tooltip positioning and visual properties when visible. + + Validates that tooltips appear at reasonable positions + with appropriate sizing for good user experience. + """ + # Set up map widget for tooltip positioning test + setup_map_widget(page_session, sample_map_with_tooltip) + + # Hover over areas that might have features + hover_feature_position(page_session, "center") + page_session.wait_for_timeout(500) + + # Check if tooltip is visible + if check_tooltip_visibility(page_session, should_be_visible=True): + position = get_tooltip_position(page_session) + + # Verify tooltip positioning makes sense + assert position is not None, "Should have tooltip position when visible" + assert position["x"] >= 0, "Tooltip x position should be non-negative" + assert position["y"] >= 0, "Tooltip y position should be non-negative" + assert position["width"] > 0, "Tooltip should have visible width" + assert position["height"] > 0, "Tooltip should have visible height" + + # Tooltip should be reasonably sized + assert 50 <= position["width"] <= 500, "Tooltip width should be reasonable" + assert 20 <= position["height"] <= 300, ( + "Tooltip height should be reasonable" + ) + + +# ============================================================================= +# INTEGRATION TESTS +# ============================================================================= + + +@pytest.mark.usefixtures("solara_test") +class TestFeatureInteractionIntegration: + """ + Test feature interaction integration with other map features and modes. + + These tests ensure that the feature interaction system works correctly + alongside other Lonboard features like bbox selection and rapid user actions. + """ + + def test_interaction_stability_under_rapid_actions( + self, + page_session, + sample_map_with_tooltip, + ): + """ + Test system stability under rapid user interactions. + + Simulates a user rapidly moving the mouse and clicking to ensure + the feature interaction system doesn't become unstable or crash. + """ + canvas = setup_map_widget(page_session, sample_map_with_tooltip) + + # Rapid interaction pattern + positions = ["center", "start", "empty", "end", "center", "empty"] + + for _ in range(3): # Repeat the pattern multiple times + for position in positions: + hover_feature_position(page_session, position) + page_session.wait_for_timeout(100) # Very rapid + + # Final stability check + assert canvas.count() > 0, "Map should remain stable after rapid interactions" + + +# ============================================================================= +# EDGE CASES AND ERROR HANDLING +# ============================================================================= + + +def test_feature_interaction_configuration_edge_cases(feature_test_layer): + """ + Test edge cases in feature interaction configuration. + + These tests ensure that unusual or boundary conditions are handled + gracefully without causing errors or unexpected behavior. + """ + # Test with boolean values explicitly + map_true = Map(feature_test_layer, show_tooltip=True) + map_false = Map(feature_test_layer, show_tooltip=False) + + assert map_true.show_tooltip is True + assert map_false.show_tooltip is False + + # Test property access as attributes + assert hasattr(map_true, "show_tooltip") + assert hasattr(map_false, "show_tooltip") + + # Test property types + assert isinstance(map_true.show_tooltip, bool) + assert isinstance(map_false.show_tooltip, bool) + + +def test_feature_interaction_with_map_configuration(feature_test_layer): + """ + Test feature interaction properties alongside other Map configurations. + + Ensures that feature interaction settings work correctly with + other map parameters like picking radius, height, etc. + """ + # Test with multiple map parameters + map_obj = Map( + feature_test_layer, + show_tooltip=True, + show_side_panel=False, + height="500px", + picking_radius=10, + ) + + validate_feature_configuration( + map_obj, + expected_show_tooltip=True, + expected_show_side_panel=False, + ) + assert map_obj.height == "500px" + assert map_obj.picking_radius == 10 diff --git a/tests/ui/test_utils.py b/tests/ui/test_utils.py new file mode 100644 index 00000000..04aa8bb3 --- /dev/null +++ b/tests/ui/test_utils.py @@ -0,0 +1,211 @@ +"""Test utilities and constants.""" + +import re +from typing import ClassVar + + +class TestConstants: + CANVAS_START_POS: ClassVar[dict[str, int]] = {"x": 25, "y": 25} + CANVAS_END_POS: ClassVar[dict[str, int]] = {"x": 75, "y": 75} + CANVAS_CENTER_POS: ClassVar[dict[str, int]] = {"x": 400, "y": 300} + CANVAS_EMPTY_POS: ClassVar[dict[str, int]] = {"x": 100, "y": 100} + TIMEOUT_WIDGET_LOAD = 30000 + TIMEOUT_BUTTON_CLICK = 10000 + TIMEOUT_BUTTON_STATE = 5000 + TIMEOUT_INTERACTION = 300 + TIMEOUT_AFTER_CLICK = 500 + + +def validate_geographic_bounds(bounds): + """Check if bounds are valid geographic coordinates.""" + if not bounds or len(bounds) != 4: + return False + + min_lon, min_lat, max_lon, max_lat = bounds + + # Skip validation if all zeros (cleared state) + if all(coord == 0 for coord in bounds): + return True + + return ( + -180 <= min_lon <= 180 + and -180 <= max_lon <= 180 + and -90 <= min_lat <= 90 + and -90 <= max_lat <= 90 + and min_lon < max_lon + and min_lat < max_lat + ) + + +def validate_geographic_data(data_text: str | None): + """Validate geographic data string format.""" + assert data_text + assert "None" not in data_text + assert "(0, 0, 0, 0)" not in data_text + + data_match = re.search( + r"Selected bounds: \(([-\deE.]+), ([-\deE.]+), ([-\deE.]+), ([-\deE.]+)\)", + data_text, + ) + assert data_match, f"Expected to find data pattern in: {data_text}" + + min_lon, min_lat, max_lon, max_lat = map(float, data_match.groups()) + assert all(coord != 0 for coord in (min_lon, min_lat, max_lon, max_lat)) + assert min_lon < max_lon + assert min_lat < max_lat + assert -180 <= min_lon <= 180 + assert -180 <= max_lon <= 180 + assert -90 <= min_lat <= 90 + assert -90 <= max_lat <= 90 + + +def draw_bbox_on_canvas(page_session, start_pos=None, end_pos=None): + """Draw bbox on canvas.""" + if start_pos is None: + start_pos = TestConstants.CANVAS_START_POS + if end_pos is None: + end_pos = TestConstants.CANVAS_END_POS + + canvas = page_session.locator("canvas").first + canvas.click(position=start_pos) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + canvas.hover(position=end_pos) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + canvas.click(position=end_pos) + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + +def start_bbox_selection(page_session): + """Start bbox selection mode.""" + select_button = page_session.locator('button[aria-label*="Select"]') + select_button.wait_for(timeout=TestConstants.TIMEOUT_BUTTON_CLICK) + select_button.click() + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + +def wait_for_canvas(page_session): + """Wait for map canvas.""" + canvas = page_session.locator("canvas").first + canvas.wait_for(timeout=TestConstants.TIMEOUT_WIDGET_LOAD) + return canvas + + +def verify_bbox_cleared(sample_map): + """Check if bbox selection is cleared.""" + bounds = sample_map.selected_bounds + return bounds is None or all(x == 0 for x in bounds) if bounds else True + + +def get_button(page_session, button_type: str): + """Get toolbar button by type.""" + button_selectors = { + "select": 'button[aria-label*="Select"]', + "cancel": 'button[aria-label*="Cancel"]', + "clear": 'button[aria-label*="Clear"]', + } + + if button_type not in button_selectors: + raise ValueError(f"Unknown button type: {button_type}") + + return page_session.locator(button_selectors[button_type]) + + +def wait_for_button(page_session, button_type: str, timeout=None): + """Wait for toolbar button.""" + if timeout is None: + timeout = TestConstants.TIMEOUT_BUTTON_STATE + + button = get_button(page_session, button_type) + button.wait_for(timeout=timeout) + return button + + +def setup_map_widget(page_session, sample_map): + """Setup map widget for testing.""" + from IPython.display import display + + assert sample_map is not None + assert hasattr(sample_map, "_model_name") + assert sample_map._model_name == "AnyModel" + + display(sample_map) + canvas = wait_for_canvas(page_session) + assert canvas.count() > 0 + return canvas + + +def click_feature_position(page_session, position_type: str = "center"): + """Click on predefined canvas position.""" + positions = { + "center": TestConstants.CANVAS_CENTER_POS, + "empty": TestConstants.CANVAS_EMPTY_POS, + "start": TestConstants.CANVAS_START_POS, + "end": TestConstants.CANVAS_END_POS, + } + + if position_type not in positions: + raise ValueError(f"Unknown position type: {position_type}") + + canvas = page_session.locator("canvas").first + canvas.click(position=positions[position_type]) + page_session.wait_for_timeout(TestConstants.TIMEOUT_AFTER_CLICK) + + +def hover_feature_position(page_session, position_type: str = "center"): + """Hover on predefined canvas position.""" + positions = { + "center": TestConstants.CANVAS_CENTER_POS, + "empty": TestConstants.CANVAS_EMPTY_POS, + "start": TestConstants.CANVAS_START_POS, + "end": TestConstants.CANVAS_END_POS, + } + + if position_type not in positions: + raise ValueError(f"Unknown position type: {position_type}") + + canvas = page_session.locator("canvas").first + canvas.hover(position=positions[position_type]) + page_session.wait_for_timeout(TestConstants.TIMEOUT_INTERACTION) + + +def check_tooltip_visibility(page_session, *, should_be_visible: bool = True): + """Check if tooltip is visible in the DOM.""" + # Look for tooltip element with the specific CSS class + tooltip = page_session.locator(".lonboard-tooltip") + + if should_be_visible: + # Wait for tooltip to appear and be visible + try: + tooltip.wait_for(timeout=2000, state="visible") + return tooltip.count() > 0 and tooltip.is_visible() + except (TimeoutError, AssertionError): + return False + else: + # Check that tooltip is not visible or doesn't exist + # We just need to check current state, not wait for it to become hidden + count = tooltip.count() + if count == 0: + return True # No tooltip exists, which is correct + return not tooltip.is_visible() # Tooltip exists but is hidden + + +def get_tooltip_content(page_session): + """Get tooltip content if visible.""" + tooltip = page_session.locator(".lonboard-tooltip") + if tooltip.count() > 0 and tooltip.is_visible(): + return tooltip.inner_text() + return None + + +def get_tooltip_position(page_session): + """Get tooltip bounding box if visible.""" + tooltip = page_session.locator(".lonboard-tooltip") + if tooltip.count() > 0 and tooltip.is_visible(): + bbox = tooltip.bounding_box() + return { + "x": bbox["x"], + "y": bbox["y"], + "width": bbox["width"], + "height": bbox["height"], + } + return None