Skip to content

Commit 34e218c

Browse files
authored
Show collaborators' cursors on map (#264)
* Implement collaborative pointers * Clean up * Rename * Remove console log * Use lonLat for cursor popup
1 parent 9a5ec4d commit 34e218c

File tree

6 files changed

+218
-10
lines changed

6 files changed

+218
-10
lines changed

packages/base/src/annotations/components/AnnotationFloater.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ const AnnotationFloater = ({
4242
>
4343
<Annotation itemId={itemId} annotationModel={model}>
4444
<div
45-
className="jGIS-Annotation-Topbar"
45+
className="jGIS-Popup-Topbar"
4646
onClick={() => {
4747
setOpenOrDelete(false);
4848
}}
4949
>
5050
<FontAwesomeIcon
5151
icon={faWindowMinimize}
52-
className="jGIS-Annotation-TopBarIcon"
52+
className="jGIS-Popup-TopBarIcon"
5353
/>
5454
</div>
5555
</Annotation>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
faArrowPointer,
3+
faWindowMinimize
4+
} from '@fortawesome/free-solid-svg-icons';
5+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
6+
import { IDict, JgisCoordinates } from '@jupytergis/schema';
7+
import React, { useState } from 'react';
8+
9+
interface ICollaboratorPointersProps {
10+
clients: IDict<ClientPointer>;
11+
}
12+
13+
export type ClientPointer = {
14+
username: string;
15+
displayName: string;
16+
color: string;
17+
coordinates: JgisCoordinates;
18+
lonLat: { latitude: number; longitude: number };
19+
};
20+
21+
const CollaboratorPointers = ({ clients }: ICollaboratorPointersProps) => {
22+
const [isOpen, setIsOpen] = useState(false);
23+
24+
return (
25+
<>
26+
{clients &&
27+
Object.values(clients).map(client => (
28+
<div
29+
className="jGIS-Popup-Wrapper"
30+
style={{
31+
left: `${client.coordinates.x}px`,
32+
top: `${client.coordinates.y}px`
33+
}}
34+
>
35+
<div
36+
key={client.username}
37+
className="jGIS-Remote-Pointer"
38+
style={{
39+
color: client.color,
40+
cursor: 'pointer'
41+
}}
42+
onClick={() => {
43+
setIsOpen(!isOpen);
44+
}}
45+
>
46+
<FontAwesomeIcon
47+
icon={faArrowPointer}
48+
className="jGIS-Remote-Pointer-Icon"
49+
/>
50+
</div>
51+
<div
52+
style={{
53+
visibility: isOpen ? 'visible' : 'hidden',
54+
background: client.color
55+
}}
56+
className="jGIS-Remote-Pointer-Popup jGIS-Floating-Pointer-Popup"
57+
>
58+
<div
59+
className="jGIS-Popup-Topbar"
60+
onClick={() => {
61+
setIsOpen(false);
62+
}}
63+
>
64+
<FontAwesomeIcon
65+
icon={faWindowMinimize}
66+
className="jGIS-Popup-TopBarIcon"
67+
/>
68+
</div>
69+
<div className="jGIS-Remote-Pointer-Popup-Name">
70+
{client.displayName}
71+
</div>
72+
<div className="jGIS-Remote-Pointer-Popup-Coordinates">
73+
<br />
74+
Pointer Location:
75+
<br />
76+
Longitude: {client.lonLat.longitude.toFixed(2)}
77+
<br />
78+
Latitude: {client.lonLat.latitude.toFixed(2)}
79+
</div>
80+
</div>
81+
</div>
82+
))}
83+
</>
84+
);
85+
};
86+
87+
export default CollaboratorPointers;

packages/base/src/mainview/mainView.tsx

+72-5
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { Coordinate } from 'ol/coordinate';
7171
import AnnotationFloater from '../annotations/components/AnnotationFloater';
7272
import { CommandIDs } from '../constants';
7373
import { FollowIndicator } from './FollowIndicator';
74+
import CollaboratorPointers, { ClientPointer } from './CollaboratorPointers';
7475

7576
interface IProps {
7677
viewModel: MainViewModel;
@@ -84,6 +85,7 @@ interface IStates {
8485
remoteUser?: User.IIdentity | null;
8586
firstLoad: boolean;
8687
annotations: IDict<IAnnotation>;
88+
clientPointers: IDict<ClientPointer>;
8789
}
8890

8991
export class MainView extends React.Component<IProps, IStates> {
@@ -95,6 +97,7 @@ export class MainView extends React.Component<IProps, IStates> {
9597

9698
this._mainViewModel = this.props.viewModel;
9799
this._mainViewModel.viewSettingChanged.connect(this._onViewChanged, this);
100+
98101
this._model = this._mainViewModel.jGISModel;
99102
this._model.themeChanged.connect(this._handleThemeChange, this);
100103

@@ -106,7 +109,6 @@ export class MainView extends React.Component<IProps, IStates> {
106109
this._onClientSharedStateChanged,
107110
this
108111
);
109-
110112
this._model.sharedLayersChanged.connect(this._onLayersChanged, this);
111113
this._model.sharedLayerTreeChanged.connect(this._onLayerTreeChange, this);
112114
this._model.sharedSourcesChanged.connect(this._onSourcesChange, this);
@@ -122,7 +124,8 @@ export class MainView extends React.Component<IProps, IStates> {
122124
lightTheme: isLightTheme(),
123125
loading: true,
124126
firstLoad: true,
125-
annotations: {}
127+
annotations: {},
128+
clientPointers: {}
126129
};
127130

128131
this._sources = [];
@@ -273,6 +276,10 @@ export class MainView extends React.Component<IProps, IStates> {
273276
});
274277
});
275278

279+
this._Map
280+
.getViewport()
281+
.addEventListener('pointermove', this._onPointerMove.bind(this));
282+
276283
if (JupyterGISModel.getOrderedLayerIds(this._model).length !== 0) {
277284
await this._updateLayersImpl(
278285
JupyterGISModel.getOrderedLayerIds(this._model)
@@ -282,15 +289,15 @@ export class MainView extends React.Component<IProps, IStates> {
282289
this._initializedPosition = true;
283290
}
284291

285-
this.setState(old => ({ ...old, loading: false }));
286-
287292
this._Map.getViewport().addEventListener('contextmenu', event => {
288293
event.preventDefault();
289294
event.stopPropagation();
290295
const coordinate = this._Map.getEventCoordinate(event);
291296
this._clickCoords = coordinate;
292297
this._contextMenu.open(event);
293298
});
299+
300+
this.setState(old => ({ ...old, loading: false }));
294301
}
295302
}
296303

@@ -969,6 +976,50 @@ export class MainView extends React.Component<IProps, IStates> {
969976
}
970977
}
971978
}
979+
980+
// cursors
981+
clients.forEach((client, clientId) => {
982+
if (!client?.user) {
983+
return;
984+
}
985+
986+
const pointer = client.pointer?.value;
987+
988+
// We already display our own cursor on mouse move
989+
if (this._model.getClientId() === clientId) {
990+
return;
991+
}
992+
993+
const clientPointers = this.state.clientPointers;
994+
let currentClientPointer = clientPointers[clientId];
995+
996+
if (pointer) {
997+
const pixel = this._Map.getPixelFromCoordinate([
998+
pointer.coordinates.x,
999+
pointer.coordinates.y
1000+
]);
1001+
1002+
const lonLat = toLonLat([pointer.coordinates.x, pointer.coordinates.y]);
1003+
1004+
if (!currentClientPointer) {
1005+
currentClientPointer = clientPointers[clientId] = {
1006+
username: client.user.username,
1007+
displayName: client.user.display_name,
1008+
color: client.user.color,
1009+
coordinates: { x: pixel[0], y: pixel[1] },
1010+
lonLat: { longitude: lonLat[0], latitude: lonLat[1] }
1011+
};
1012+
}
1013+
1014+
currentClientPointer.coordinates.x = pixel[0];
1015+
currentClientPointer.coordinates.y = pixel[1];
1016+
clientPointers[clientId] = currentClientPointer;
1017+
} else {
1018+
delete clientPointers[clientId];
1019+
}
1020+
1021+
this.setState(old => ({ ...old, clientPointers: clientPointers }));
1022+
});
9721023
};
9731024

9741025
private _onSharedOptionsChanged(
@@ -1213,6 +1264,20 @@ export class MainView extends React.Component<IProps, IStates> {
12131264
}
12141265
}
12151266

1267+
private _onPointerMove(e: MouseEvent) {
1268+
const pixel = this._Map.getEventPixel(e);
1269+
const coordinates = this._Map.getCoordinateFromPixel(pixel);
1270+
1271+
this._syncPointer(coordinates);
1272+
}
1273+
1274+
private _syncPointer = throttle((coordinates: Coordinate) => {
1275+
const pointer = {
1276+
coordinates: { x: coordinates[0], y: coordinates[1] }
1277+
};
1278+
this._model.syncPointer(pointer);
1279+
});
1280+
12161281
private _handleThemeChange = (): void => {
12171282
const lightTheme = isLightTheme();
12181283

@@ -1242,7 +1307,7 @@ export class MainView extends React.Component<IProps, IStates> {
12421307
left: screenPosition.x,
12431308
top: screenPosition.y
12441309
}}
1245-
className={'jGIS-Annotation-Wrapper'}
1310+
className={'jGIS-Popup-Wrapper'}
12461311
>
12471312
<AnnotationFloater
12481313
itemId={key}
@@ -1253,6 +1318,7 @@ export class MainView extends React.Component<IProps, IStates> {
12531318
)
12541319
);
12551320
})}
1321+
12561322
<div
12571323
className="jGIS-Mainview"
12581324
style={{
@@ -1263,6 +1329,7 @@ export class MainView extends React.Component<IProps, IStates> {
12631329
>
12641330
<Spinner loading={this.state.loading} />
12651331
<FollowIndicator remoteUser={this.state.remoteUser} />
1332+
<CollaboratorPointers clients={this.state.clientPointers} />
12661333

12671334
<div
12681335
ref={this.divRef}

packages/schema/src/interfaces.ts

+6
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export interface IViewPortState {
3535
coordinates: JgisCoordinates;
3636
zoom: number;
3737
}
38+
39+
export type Pointer = {
40+
coordinates: { x: number; y: number };
41+
};
3842
export interface IDict<T = any> {
3943
[key: string]: T;
4044
}
@@ -68,6 +72,7 @@ export interface ISelection {
6872
export interface IJupyterGISClientState {
6973
selected: { value?: { [key: string]: ISelection }; emitter?: string | null };
7074
viewportState: { value?: IViewPortState; emitter?: string | null };
75+
pointer: { value?: Pointer; emitter?: string | null };
7176
user: User.IIdentity;
7277
remoteUser?: number;
7378
toolbarForm?: IDict;
@@ -193,6 +198,7 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel {
193198

194199
syncViewport(viewport?: IViewPortState, emitter?: string): void;
195200
syncSelected(value: { [key: string]: ISelection }, emitter?: string): void;
201+
syncPointer(pointer?: Pointer, emitter?: string): void;
196202
setUserToFollow(userId?: number): void;
197203

198204
getClientId(): number;

packages/schema/src/model.ts

+8
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { JupyterGISDoc } from './doc';
2121
import {
2222
IViewPortState,
23+
Pointer,
2324
IAnnotationModel,
2425
IJGISLayerDocChange,
2526
IJGISLayerTreeDocChange,
@@ -393,6 +394,13 @@ export class JupyterGISModel implements IJupyterGISModel {
393394
});
394395
}
395396

397+
syncPointer(pointer?: Pointer, emitter?: string): void {
398+
this.sharedModel.awareness.setLocalStateField('pointer', {
399+
value: pointer,
400+
emitter: emitter
401+
});
402+
}
403+
396404
syncSelected(value: { [key: string]: ISelection }, emitter?: string): void {
397405
this.sharedModel.awareness.setLocalStateField('selected', {
398406
value,

python/jupytergis_lab/style/base.css

+43-3
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child {
218218
box-sizing: border-box;
219219
}
220220

221-
.jGIS-Annotation-Wrapper {
221+
.jGIS-Popup-Wrapper {
222222
position: absolute;
223223
opacity: 1;
224224
transition: opacity 0.1s linear 0.15s;
@@ -319,12 +319,12 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child {
319319
flex-grow: 1;
320320
}
321321

322-
.jGIS-Annotation-Topbar {
322+
.jGIS-Popup-Topbar {
323323
display: flex;
324324
flex-direction: row-reverse;
325325
}
326326

327-
.jGIS-Annotation-TopBarIcon {
327+
.jGIS-Popup-TopBarIcon {
328328
cursor: pointer;
329329
transform: rotate(180deg);
330330
}
@@ -362,6 +362,46 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child {
362362
max-width: 68px;
363363
}
364364

365+
.jGIS-Remote-Pointer {
366+
position: absolute;
367+
transition-property: left, top, width, height;
368+
transition-duration: 150ms;
369+
transition-timing-function: ease;
370+
z-index: 20;
371+
}
372+
373+
.jGIS-Remote-Pointer-Icon:hover {
374+
scale: 1.3;
375+
}
376+
377+
.jGIS-Floating-Pointer-Popup {
378+
position: absolute;
379+
width: 160px;
380+
box-shadow: var(--jp-elevation-z6);
381+
z-index: 40;
382+
max-height: 400px;
383+
overflow: auto;
384+
}
385+
386+
.jGIS-Remote-Pointer-Popup {
387+
margin-left: 7px;
388+
margin-top: 14px;
389+
color: #fff;
390+
padding: 1em;
391+
border-radius: 0.5em;
392+
font-size: 12px;
393+
line-height: 1.2;
394+
}
395+
396+
.jGIS-Remote-Pointer-Popup-Name {
397+
font-size: 16px;
398+
color: #333;
399+
}
400+
401+
.jGIS-Remote-Pointer-Popup-Coordinates {
402+
font-size: 14px;
403+
}
404+
365405
/* Hack to remove the malformed form button */
366406
.object-property-expand {
367407
display: none;

0 commit comments

Comments
 (0)