Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Select by bounding box #417

Merged
merged 38 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e8e4da4
prototype showing selection drawing on top of the map
isaacbrodsky Mar 8, 2024
7db62b2
cleanup
isaacbrodsky Mar 8, 2024
48d4d33
restyle
isaacbrodsky Mar 8, 2024
22f79f7
[wip] select bbox on map
batpad Mar 13, 2024
add1a76
add pickobjects to pick selected objects
batpad Mar 13, 2024
3244bcd
fix width calculation bug so that it picks objects correctly
batpad Mar 13, 2024
d726e75
add some (ugly) UI for user to trigger starting a selection and clear…
batpad Mar 14, 2024
745e989
merge commit + slightly ugly code to get hover interaction working fo…
batpad Mar 23, 2024
82ce22b
lint
batpad Mar 23, 2024
0515769
send selected_bounds back to python after selection
batpad Mar 25, 2024
813abc5
lintfix
batpad Mar 26, 2024
0459aaa
convert lets that should be consts to consts
batpad Mar 26, 2024
5310e8e
remove point layer when initially clicking since we now show box on h…
batpad Mar 26, 2024
1ab3413
cleanups: remove unneeded point layer, move getPolygon to util, remov…
batpad Mar 26, 2024
39dda64
minor, remove unused var
batpad Mar 26, 2024
2245241
add selected bbox to each layer as well as the map
batpad Mar 30, 2024
5b3c8b6
Merge branch 'main' into bbox-map-select
kylebarron Apr 2, 2024
b13c8d1
Use tuple on Python side
kylebarron Apr 2, 2024
aa0b556
Rename getPolygon to makePolygon
kylebarron Apr 2, 2024
c4080fa
Fix bbox map select feature (#567)
vgeorge Jul 11, 2024
f6ba0fc
Merge branch 'main' into bbox-map-select
vgeorge Jul 16, 2024
011fd34
Replace reducer with XState
vgeorge Jul 22, 2024
17ea88f
Fix type definitions for mouse events
vgeorge Jul 22, 2024
1a22f5a
Apply selected_bounds to layers
vgeorge Jul 22, 2024
0139e83
Replace rate limit with throttle
vgeorge Jul 22, 2024
560868b
Define default value for environment variable
vgeorge Jul 22, 2024
0ea37d8
Clear unused elements
vgeorge Jul 22, 2024
91ed1ba
Add property selected_bounds to BaseArrowLayer
vgeorge Jul 24, 2024
b709c99
Setup NextUI component library and use icon on toolbar
vgeorge Jul 24, 2024
f17c416
Rename files to avoid naming conflict with existing state.ts file
vgeorge Jul 24, 2024
b83a91d
Allow cancel/clear bbox
vgeorge Jul 24, 2024
a24be14
Allow cancelling after first point was selected
vgeorge Jul 24, 2024
90e4cfa
Merge branch 'main' into bbox-map-select
vgeorge Jul 24, 2024
cc67f65
lint fixes
vgeorge Jul 24, 2024
0a4306a
Remove selected_bounds from BaseArrowLayer as it is already available…
vgeorge Jul 26, 2024
127ac5e
Remove unnecessary state
vgeorge Jul 26, 2024
fd7dbc3
Update bbox bounds also when it is null
vgeorge Jul 26, 2024
9724d6a
Clean up unused action
vgeorge Jul 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lonboard/_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ def _add_extension_traits(self, extensions: Sequence[BaseExtension]):
- Default: `False`
"""

selected_bounds = traitlets.List(
trait=traitlets.Float(allow_none=False),
allow_none=True,
default_value=None,
sync=True,
kylebarron marked this conversation as resolved.
Show resolved Hide resolved
)
"""
Bounds selected by the user, represented as a list of floats
representing [minx, miny, maxx, maxy]
We make this available on the map, as well as each layer, for convenience.
This tries to copy Shapely in representing bounds:
https://shapely.readthedocs.io/en/stable/manual.html#object.bounds
"""

selected_index = traitlets.Int(None, allow_none=True).tag(sync=True)
"""
The positional index of the most-recently clicked on row of data.
Expand Down
15 changes: 15 additions & 0 deletions lonboard/_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ def __init__(
- Default: `5`
"""

selected_bounds = traitlets.List(
trait=traitlets.Float(allow_none=False),
allow_none=True,
default_value=None,
sync=True,
)
"""
Bounds selected by the user, represented as a list of floats
representing [minx, miny, maxx, maxy]
This tries to copy Shapely in representing bounds:
https://shapely.readthedocs.io/en/stable/manual.html#object.bounds

QUESTION: is this how we should represent bounds for the python object?
"""

basemap_style = traitlets.Unicode(CartoBasemap.PositronNoLabels).tag(sync=True)
"""
A MapLibre-compatible basemap style.
Expand Down
196 changes: 178 additions & 18 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { createRender, useModelState, useModel } from "@anywidget/react";
import type { Initialize, Render } from "@anywidget/types";
import Map from "react-map-gl/maplibre";
import DeckGL from "@deck.gl/react/typed";
import DeckGL, { DeckGLRef } from "@deck.gl/react/typed";
import { PolygonLayer } from "@deck.gl/layers/typed";
import type { PickingInfo } from "@deck.gl/core/typed";
import { MapViewState, type Layer } from "@deck.gl/core/typed";
import { BaseLayerModel, initializeLayer } from "./model/index.js";
import type { WidgetModel } from "@jupyter-widgets/base";
import { initParquetWasm } from "./parquet.js";
import { getTooltip } from "./tooltip/index.js";
import { isDefined, loadChildModels } from "./util.js";
import { getPolygon, isDefined, loadChildModels } from "./util.js";
import { v4 as uuidv4 } from "uuid";
import { Message } from "./types.js";
import { flyTo } from "./actions/fly-to.js";
Expand Down Expand Up @@ -62,19 +64,28 @@ async function getChildModelState(
}

function App() {
let model = useModel();
const model = useModel();

let [pythonInitialViewState] = useModelState<MapViewState>(
const [pythonInitialViewState] = useModelState<MapViewState>(
"_initial_view_state",
);
let [mapStyle] = useModelState<string>("basemap_style");
let [mapHeight] = useModelState<number>("_height");
let [showTooltip] = useModelState<boolean>("show_tooltip");
let [pickingRadius] = useModelState<number>("picking_radius");
let [useDevicePixels] = useModelState<number | boolean>("use_device_pixels");
let [parameters] = useModelState<object>("parameters");

let [initialViewState, setInitialViewState] = useState(
const [mapStyle] = useModelState<string>("basemap_style");
const [mapHeight] = useModelState<number>("_height");
const [showTooltip] = useModelState<boolean>("show_tooltip");
const [pickingRadius] = useModelState<number>("picking_radius");
const [boundsModel, setBoundsModel] =
useModelState<Array<number>>("selected_bounds");
const [selectionMode, setSelectionMode] = useState<boolean | string>(false);
const [selectionObjectCount, setSelectionObjectCount] = useState<
boolean | number
>(false);
const [hoverBBoxLayer, setHoverBBoxLayer] = useState<any>(false);
const [useDevicePixels] = useModelState<number | boolean>(
"use_device_pixels",
);
const [parameters] = useModelState<object>("parameters");

const [initialViewState, setInitialViewState] = useState(
pythonInitialViewState,
);

Expand All @@ -91,15 +102,15 @@ function App() {
});

const [mapId] = useState(uuidv4());

let [subModelState, setSubModelState] = useState<
const mapRef = useRef<DeckGLRef>(null);
const [subModelState, setSubModelState] = useState<
Record<string, BaseLayerModel>
>({});

let [childLayerIds] = useModelState<string[]>("layers");
const [childLayerIds] = useModelState<string[]>("layers");

// Fake state just to get react to re-render when a model callback is called
let [stateCounter, setStateCounter] = useState<Date>(new Date());
const [stateCounter, setStateCounter] = useState<Date>(new Date());

useEffect(() => {
const callback = async () => {
Expand Down Expand Up @@ -144,8 +155,154 @@ function App() {
}
}, []);

// State is an array of: [screen coordinates, geographic coordinates]
const [selectionStart, setSelectionStart] = useState<
undefined | [[number, number], number[] | undefined]
>();
const [selectionEnd, setSelectionEnd] = useState<
undefined | [[number, number], number[] | undefined]
>();

function onSelectClick() {
if (!selectionMode) {
setSelectionMode("selecting");
}
if (selectionMode === "selected") {
setSelectionMode(false);
setSelectionStart(undefined);
setSelectionEnd(undefined);
}
}

function onMapClick(info: PickingInfo) {
if (!selectionMode || selectionMode === "selected") return;
if (selectionStart !== undefined && selectionEnd === undefined) {
setSelectionEnd([[info.x, info.y], info.coordinate]);
const pt1 = selectionStart[0];
const pt2 = [info.x, info.y];

const width = Math.abs(pt2[0] - pt1[0]);
const height = Math.abs(pt2[1] - pt1[1]);
const left = Math.min(pt1[0], pt2[0]);
const top = Math.min(pt1[1], pt2[1]);
const selectedObjects = mapRef.current?.pickObjects({
x: left,
y: top,
width,
height,
});
setSelectionMode("selected");
setHoverBBoxLayer(false);
setSelectionObjectCount(selectedObjects ? selectedObjects.length : 0);

// set this to what Shapely uses to represent Bounds
const bounds = [
Math.min(pt1[0], pt2[0]),
Math.min(pt1[1], pt2[1]),
Math.max(pt1[0], pt2[0]),
Math.max(pt1[1], pt2[1]),
];
setBoundsModel(bounds);

// now we need to set the selected_bounds on each layer
loadChildModels(model.widget_manager, childLayerIds).then(layerModels => {
layerModels.forEach(layer => {
layer.set('selected_bounds', bounds);
layer.save_changes();
});
}).catch(e => {
console.log('error setting selected_bounds state on layer models', e);
});
} else {
setSelectionStart([[info.x, info.y], info.coordinate]);
setSelectionEnd(undefined);
}
}

function onMapHover(hoverInfo: PickingInfo) {
if (selectionMode !== "selecting") return;
const hoverCoords = hoverInfo.coordinate;
if (selectionStart && hoverCoords) {
const pt1 = selectionStart[1];
const pt2 = hoverCoords;
if (!pt1 || !pt2) return;
const data = [
{
polygon: getPolygon(pt1, pt2),
},
];
const bboxLayer = new PolygonLayer({
id: "selection-layer",
data,
filled: true,
getFillColor: [0, 0, 0, 50],
stroked: true,
getLineWidth: 2,
lineWidthUnits: "pixels",
getPolygon: (d) => d.polygon,
});
console.log(bboxLayer);
setHoverBBoxLayer(bboxLayer);
}
return;
}

const selectionIndicator = useMemo(() => {
if (!selectionMode) return undefined;
if (selectionStart && selectionEnd) {
const pt1 = selectionStart[1];
const pt2 = selectionEnd[1];
if (!pt1 || !pt2) return undefined;
const data = [
{
polygon: getPolygon(pt1, pt2),
},
];
return new PolygonLayer({
id: "selection-layer",
data,
filled: true,
getFillColor: [0, 0, 0, 30],
stroked: true,
getLineWidth: 2,
lineWidthUnits: "pixels",
getPolygon: (d) => d.polygon,
});
} else {
return undefined;
}
}, [selectionStart, selectionEnd, selectionMode]);

if (selectionIndicator) {
layers.push(selectionIndicator);
}

if (hoverBBoxLayer) {
layers.push(hoverBBoxLayer);
}

return (
<div id={`map-${mapId}`} style={{ height: mapHeight || "100%" }}>
<div id={`map-${mapId}`} style={{ height: "100%" }}>
<div
style={{
position: "absolute",
top: "2px",
right: "2px",
backgroundColor: "#fff",
padding: "2px",
zIndex: "1000",
height: "12px",
}}
onClick={onSelectClick}
>
{!selectionMode ? "Click to start selecting" : ""}
{selectionMode === "selecting"
? "Click two points on map to draw bounding box"
: ""}
{selectionMode === "selected"
? `${selectionObjectCount} objects selected. Click to Unselect.`
: ""}
</div>
<DeckGL
initialViewState={
["longitude", "latitude", "zoom"].every((key) =>
Expand All @@ -159,6 +316,9 @@ function App() {
// @ts-expect-error
getTooltip={showTooltip && getTooltip}
pickingRadius={pickingRadius}
onClick={onMapClick}
onHover={onMapHover}
ref={mapRef}
useDevicePixels={isDefined(useDevicePixels) ? useDevicePixels : true}
// https://deck.gl/docs/api-reference/core/deck#_typedarraymanagerprops
_typedArrayManagerProps={{
Expand Down
1 change: 1 addition & 0 deletions src/model/base-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export abstract class BaseLayerModel extends BaseModel {
this.initRegularAttribute("visible", "visible");
this.initRegularAttribute("opacity", "opacity");
this.initRegularAttribute("auto_highlight", "autoHighlight");
this.initRegularAttribute("selected_bounds", "selectedBounds");

this.extensions = [];
}
Expand Down
4 changes: 4 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ export async function loadChildModels(
export function isDefined<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}

export function getPolygon(pt1: number[], pt2: number[]) {
return [pt1, [pt1[0], pt2[1]], pt2, [pt2[0], pt1[1]], pt1];
}
Loading