Starting again...
This commit is contained in:
1 parent
1c403ae819
commit
e75b2f670c
99 files changed
+104
-5791
No files matched your search
@@ -0,0 +1,24 @@
|
||||
.container {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.container strong {
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.container p {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container a {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import './ExploreContainer.css';
|
||||
|
||||
interface ContainerProps { }
|
||||
|
||||
const ExploreContainer: React.FC<ContainerProps> = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<strong>Ready to create an app?</strong>
|
||||
<p>Start with Ionic <a target="_blank" rel="noopener noreferrer" href="https://ionicframework.com/docs/components">UI Components</a></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreContainer;
|
||||
@@ -1,26 +0,0 @@
|
||||
import react from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { arrowBackCircleOutline } from 'ionicons/icons';
|
||||
import './BackForward.css';
|
||||
|
||||
export interface BackProperties {}
|
||||
|
||||
export const Back: react.FC<BackProperties> = (props: BackProperties) => {
|
||||
const onClickHandler = (event: any) => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonButton
|
||||
onClick={onClickHandler}
|
||||
size='large'
|
||||
className='back-forward'
|
||||
shape='round'
|
||||
fill='solid'
|
||||
>
|
||||
<IonIcon slot='icon-only' icon={arrowBackCircleOutline} />
|
||||
</IonButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default Back;
|
||||
@@ -1,5 +0,0 @@
|
||||
ion-button.back-forward {
|
||||
--opacity: 0.6;
|
||||
--ion-background-color: white;
|
||||
--ionicon-stroke-width: 48px;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import react from 'react';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { arrowForwardCircleOutline } from 'ionicons/icons';
|
||||
import './BackForward.css';
|
||||
|
||||
export interface ForwardProperties {}
|
||||
|
||||
export const Forward: react.FC<ForwardProperties> = (props: ForwardProperties) => {
|
||||
const onClickHandler = (event: any) => {
|
||||
window.history.forward();
|
||||
};
|
||||
|
||||
return (
|
||||
<IonButton
|
||||
onClick={onClickHandler}
|
||||
size='large'
|
||||
className='back-forward'
|
||||
shape='round'
|
||||
fill='solid'
|
||||
>
|
||||
<IonIcon slot='icon-only' icon={arrowForwardCircleOutline} />
|
||||
</IonButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default Forward;
|
||||
@@ -1,6 +0,0 @@
|
||||
ion-button.get-location {
|
||||
--opacity: 0.6;
|
||||
--ion-background-color: white;
|
||||
margin-left: calc(50% - 20px);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Geolocation } from '@awesome-cordova-plugins/geolocation';
|
||||
|
||||
import './GetLocation.css';
|
||||
import { IonButton, IonIcon } from '@ionic/react';
|
||||
import { locateOutline } from 'ionicons/icons';
|
||||
import { useAtom } from 'jotai';
|
||||
import { setCenterAtom } from '../../App';
|
||||
import { locationAtom } from '../map/CurrentLocation';
|
||||
|
||||
const GetLocation: React.FC<{}> = () => {
|
||||
const [, setCenter] = useAtom(setCenterAtom);
|
||||
const [, setLocation] = useAtom(locationAtom);
|
||||
|
||||
const onClickHandler = (event: any) => {
|
||||
console.log('Click handler');
|
||||
Geolocation.getCurrentPosition().then((position) => {
|
||||
console.log(
|
||||
`Click handler, position: ${position.coords.latitude}, ${position.coords.longitude}`
|
||||
);
|
||||
setCenter({
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude,
|
||||
});
|
||||
setLocation({
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<IonButton
|
||||
onClick={onClickHandler}
|
||||
className='get-location get-position'
|
||||
shape='round'
|
||||
size='small'
|
||||
fill='solid'
|
||||
>
|
||||
<IonIcon slot='icon-only' icon={locateOutline} color='white' />
|
||||
</IonButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default GetLocation;
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
IonModal,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonContent,
|
||||
IonList,
|
||||
} from '@ionic/react';
|
||||
import react, { useRef, useState } from 'react';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
import cssDialog from './dialogs.module.css';
|
||||
|
||||
export interface ExplorerProperties {}
|
||||
|
||||
export const isOpenAtom = atom(false);
|
||||
|
||||
export const Explorer: react.FC<ExplorerProperties> = (
|
||||
props: ExplorerProperties
|
||||
) => {
|
||||
const modal = useRef<HTMLIonModalElement>(null);
|
||||
const [isOpen, setIsOpen] = useAtom(isOpenAtom);
|
||||
|
||||
const dismiss = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonModal
|
||||
ref={modal}
|
||||
className={cssDialog.modal}
|
||||
isOpen={isOpen}
|
||||
onDidDismiss={dismiss}
|
||||
>
|
||||
<IonToolbar>
|
||||
<IonTitle>{i18n.explorer.nearBy}</IonTitle>
|
||||
<IonButtons slot='end'>
|
||||
<IonButton onClick={dismiss}>{i18n.common.close}</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
<IonContent>
|
||||
<IonList></IonList>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Explorer;
|
||||
@@ -1,8 +0,0 @@
|
||||
.inputFile {
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import GPX from '../../lib/gpx-parser-builder/src/gpx';
|
||||
|
||||
import css from './GpxImport.module.css';
|
||||
import { IonIcon, IonItem } from '@ionic/react';
|
||||
import { cloudUpload } from 'ionicons/icons';
|
||||
import { findStartTime } from '../../lib/gpx';
|
||||
import dispatch from '../../workers/dispatcher-main';
|
||||
import { intToGpxId } from '../../lib/ids';
|
||||
|
||||
const GpxImport: React.FC<{}> = () => {
|
||||
const onChangeHandler = (event: any) => {
|
||||
console.log('On change handler');
|
||||
const file: File = event.target.files[0];
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsText(file);
|
||||
|
||||
fileReader.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
// this will then display a text file
|
||||
console.log(fileReader.result);
|
||||
const gpx = GPX.parse(fileReader.result);
|
||||
console.log(`gpx: ${JSON.stringify(gpx)}`);
|
||||
const startTime = new Date(findStartTime(gpx)!);
|
||||
dispatch({
|
||||
action: 'pruneAndSaveImportedGpx',
|
||||
params: {
|
||||
id: { gpx: intToGpxId(startTime.valueOf()) },
|
||||
gpx: gpx,
|
||||
tech: {
|
||||
lastModified: new Date(file.lastModified).toISOString(),
|
||||
importDate: new Date().toISOString(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
// pushGpx(db, {
|
||||
// gpx,
|
||||
// metadata: {
|
||||
// lastModified: new Date(file.lastModified).toISOString(),
|
||||
// importDate: new Date().toISOString(),
|
||||
// name: file.name,
|
||||
// size: file.size,
|
||||
// type: file.type,
|
||||
// },
|
||||
// });
|
||||
},
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<IonItem>
|
||||
<input
|
||||
type='file'
|
||||
id='gpx-import'
|
||||
className={css.inputFile}
|
||||
accept='.gpx'
|
||||
onChange={onChangeHandler}
|
||||
/>
|
||||
<label htmlFor='gpx-import'>
|
||||
<IonIcon slot='icon-only' icon={cloudUpload} title='import' />
|
||||
</label>
|
||||
</IonItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default GpxImport;
|
||||
@@ -1,78 +0,0 @@
|
||||
import react, { useRef } from 'react';
|
||||
|
||||
import {
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonModal,
|
||||
IonRadio,
|
||||
IonRadioGroup,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from '@ionic/react';
|
||||
import { layersOutline } from 'ionicons/icons';
|
||||
import { nonFakeTileProviders } from '../map/tile-providers';
|
||||
import { tileProviderAtom } from '../../App';
|
||||
import i18n from '../../i18n/index';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import cssDialog from './dialogs.module.css';
|
||||
|
||||
export interface MapChooserProperties {}
|
||||
|
||||
export const MapChooser: react.FC<MapChooserProperties> = (
|
||||
props: MapChooserProperties
|
||||
) => {
|
||||
const modal = useRef<HTMLIonModalElement>(null);
|
||||
|
||||
const dismiss = () => {
|
||||
modal.current?.dismiss();
|
||||
};
|
||||
|
||||
const [tileProvider, setTileProvider] = useAtom(tileProviderAtom);
|
||||
|
||||
const changeHandler = (event: any) => {
|
||||
setTileProvider(event.detail.value);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonButton id='open-TileServerChooser'>
|
||||
<IonIcon slot='icon-only' icon={layersOutline} />
|
||||
</IonButton>
|
||||
<IonModal
|
||||
trigger='open-TileServerChooser'
|
||||
ref={modal}
|
||||
className={cssDialog.modal}
|
||||
>
|
||||
<IonToolbar>
|
||||
<IonTitle>{i18n.mapChooser.chooseYourMap}</IonTitle>
|
||||
<IonButtons slot='end'>
|
||||
<IonButton onClick={() => dismiss()}>{i18n.common.close}</IonButton>
|
||||
</IonButtons>
|
||||
</IonToolbar>
|
||||
<IonContent>
|
||||
<IonList>
|
||||
<IonRadioGroup value={tileProvider} onIonChange={changeHandler}>
|
||||
{Object.keys(nonFakeTileProviders).map((provider) => {
|
||||
return (
|
||||
<IonItem key={provider}>
|
||||
<IonLabel> {nonFakeTileProviders[provider].name}</IonLabel>
|
||||
<IonRadio slot='start' value={provider} />
|
||||
</IonItem>
|
||||
);
|
||||
})}
|
||||
</IonRadioGroup>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapChooser;
|
||||
@@ -1,15 +0,0 @@
|
||||
.modal {
|
||||
--height: 100%;
|
||||
--border-radius: 16px;
|
||||
--box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.modal ion-toolbar {
|
||||
--background: rgba(14, 116, 144, 0.7);
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.modal ion-content {
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import react from 'react';
|
||||
import Marker from './Marker';
|
||||
import { geoPoint, Rectangle, TileKeyObject } from './types';
|
||||
|
||||
export interface CurrentLocationProperties {
|
||||
keyObject?: TileKeyObject;
|
||||
zoom?: number;
|
||||
viewPort?: Rectangle;
|
||||
}
|
||||
|
||||
const initialLocation: geoPoint | null = null;
|
||||
export const locationAtom = atom<geoPoint | null>(initialLocation);
|
||||
|
||||
export const CurrentLocation: react.FC<CurrentLocationProperties> = (
|
||||
props: CurrentLocationProperties
|
||||
) => {
|
||||
const [location] = useAtom(locationAtom);
|
||||
console.log(
|
||||
`Rendering CurrentLocation, location:${JSON.stringify(location)}`
|
||||
);
|
||||
|
||||
return location !== null ? (
|
||||
<Marker
|
||||
key='currentLocation'
|
||||
id='currentLocation'
|
||||
coordinates={location}
|
||||
icon={
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={8}
|
||||
fill='blue'
|
||||
opacity='90%'
|
||||
stroke='white'
|
||||
strokeWidth={3}
|
||||
strokeOpacity='100%'
|
||||
/>
|
||||
}
|
||||
keyObject={props.keyObject}
|
||||
zoom={props.zoom}
|
||||
viewPort={props.viewPort}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default CurrentLocation;
|
||||
@@ -1,47 +0,0 @@
|
||||
import react, { startTransition, useEffect, useState } from 'react';
|
||||
import dispatch from '../../workers/dispatcher-main';
|
||||
import Trk from './Trk';
|
||||
import { TileKeyObject, Rectangle } from './types';
|
||||
import getUri from '../../lib/ids';
|
||||
|
||||
export interface GpxProperties {
|
||||
id: string;
|
||||
keyObject: TileKeyObject;
|
||||
zoom: number;
|
||||
viewPort: Rectangle;
|
||||
}
|
||||
|
||||
export const Gpx: react.FC<GpxProperties> = (props: GpxProperties) => {
|
||||
const [gpx, setGpx] = useState<any>([]);
|
||||
useEffect(() => {
|
||||
const getGpx = async () => {
|
||||
const gpx = await dispatch({
|
||||
action: 'getGpx',
|
||||
params: {
|
||||
id: props.id,
|
||||
},
|
||||
});
|
||||
console.log(`<Gpx>, gpx: ${JSON.stringify(gpx)}`);
|
||||
startTransition(() => setGpx(gpx));
|
||||
};
|
||||
getGpx();
|
||||
}, [props.id]);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{gpx
|
||||
.filter((row: any) => row.doc.type === 'trk')
|
||||
.map((row: any) => (
|
||||
<Trk
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
keyObject={props.keyObject}
|
||||
zoom={props.zoom}
|
||||
viewPort={props.viewPort}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gpx;
|
||||
@@ -1,51 +0,0 @@
|
||||
import react, { startTransition, useEffect, useState } from 'react';
|
||||
import dispatch from '../../workers/dispatcher-main';
|
||||
import Gpx from './Gpx';
|
||||
import { TileKeyObject, Rectangle } from './types';
|
||||
|
||||
export interface GpxesProperties {
|
||||
keyObject?: TileKeyObject;
|
||||
zoom?: number;
|
||||
viewPort?: Rectangle;
|
||||
}
|
||||
|
||||
export const Gpxes: react.FC<GpxesProperties> = (props: GpxesProperties) => {
|
||||
const [visibleGpxes, setVisibleGpxes] = useState<any>([]);
|
||||
useEffect(() => {
|
||||
const getVisibleGpxes = async () => {
|
||||
const gpxes = await dispatch({
|
||||
action: 'getGpxesForViewport',
|
||||
params: {
|
||||
viewport: props.viewPort,
|
||||
zoomLevel: props.keyObject?.zoomLevel,
|
||||
},
|
||||
});
|
||||
console.log(`Gpxes, visibles: ${JSON.stringify(gpxes)}`);
|
||||
startTransition(() => setVisibleGpxes(gpxes));
|
||||
};
|
||||
if (props.viewPort !== undefined) {
|
||||
getVisibleGpxes();
|
||||
}
|
||||
}, [
|
||||
props.viewPort?.bottomRight.x,
|
||||
props.viewPort?.bottomRight.y,
|
||||
props.viewPort?.topLeft.x,
|
||||
props.viewPort?.topLeft.y,
|
||||
props.keyObject?.zoomLevel,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
{visibleGpxes.map((id: string) => (
|
||||
<Gpx
|
||||
key={id}
|
||||
id={id}
|
||||
keyObject={props.keyObject!}
|
||||
zoom={props.zoom!}
|
||||
viewPort={props.viewPort!}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gpxes;
|
||||
@@ -1,5 +0,0 @@
|
||||
.handler {
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { createEvent, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Handlers } from './Handlers';
|
||||
import { Transformation } from './LiveMap';
|
||||
import { Point } from './types';
|
||||
describe('The Handlers component ', () => {
|
||||
test('is just an empty div', () => {
|
||||
const transformMap = (t: Transformation) => {};
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
// screen.debug();
|
||||
const handlers = screen.getByRole('presentation');
|
||||
// screen.debug();
|
||||
expect(handlers).toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="handler"
|
||||
role="presentation"
|
||||
/>
|
||||
`);
|
||||
});
|
||||
/* test('handle mouseDown / mouseMove events', () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseDown(handlers, {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toMatchInlineSnapshot(`
|
||||
Arguments [
|
||||
Object {
|
||||
"x": 10,
|
||||
"y": 30,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
]
|
||||
`);
|
||||
});
|
||||
test('does *not* handle mouseMove events not preceded by a mouseDown', () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toBeUndefined();
|
||||
});
|
||||
|
||||
test('A mouseUp event disable further mouseMove events', () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseDown(handlers, {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseUp(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toBeUndefined();
|
||||
});
|
||||
|
||||
test('A mouseLeave event disable further mouseMove events', () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(handlers, createEvent.mouseOver(handlers));
|
||||
fireEvent(handlers, createEvent.mouseEnter(handlers));
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseDown(handlers, {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
fireEvent(handlers, createEvent.mouseOut(handlers));
|
||||
fireEvent(handlers, createEvent.mouseLeave(handlers));
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toBeUndefined();
|
||||
});
|
||||
|
||||
test('throttle mouseMove events', () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseDown(handlers, {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 30,
|
||||
clientY: 60,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toMatchInlineSnapshot(`
|
||||
Arguments [
|
||||
Object {
|
||||
"x": 10,
|
||||
"y": 30,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('throttle mouseMove events', async () => {
|
||||
var transformMapParams: any;
|
||||
function transformMap(
|
||||
deltaShift: Point | null,
|
||||
deltaZoom: number | null,
|
||||
zoomCenter: Point | null
|
||||
) {
|
||||
console.log(`transformMap${JSON.stringify(arguments)}`);
|
||||
transformMapParams = arguments;
|
||||
}
|
||||
render(<Handlers transformMap={transformMap} />);
|
||||
const handlers = screen.getByRole('presentation');
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseDown(handlers, {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
})
|
||||
);
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 20,
|
||||
clientY: 50,
|
||||
})
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
fireEvent(
|
||||
handlers,
|
||||
createEvent.mouseMove(handlers, {
|
||||
clientX: 5,
|
||||
clientY: 5,
|
||||
})
|
||||
);
|
||||
// screen.debug();
|
||||
expect(transformMapParams).toMatchInlineSnapshot(`
|
||||
Arguments [
|
||||
Object {
|
||||
"x": -15,
|
||||
"y": -45,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
]
|
||||
`);
|
||||
}); */
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
import react, { useRef } from 'react';
|
||||
|
||||
import { Point } from './types';
|
||||
import './Handler.css';
|
||||
import { handlersConfig } from './config';
|
||||
import { Transformation } from './LiveMap';
|
||||
import { isOpenAtom } from '../dialogs/Explorer';
|
||||
import cache from '../../lib/cache';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
*
|
||||
*/
|
||||
export interface HandlersProperties {
|
||||
/** The transformation to apply on the `<LiveMap>` parent */
|
||||
transformMap: (t: Transformation) => void;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns A div with the following handlers
|
||||
* * mouseLeave, mouseDown and mouseUp to track the mouse state
|
||||
* * mouseMove to shift the map if the mouse is down
|
||||
* * doubleClick to zoom in
|
||||
* * wheel to zoom in and out
|
||||
* * touchStart, touchEnd and touchCancel to track touch state
|
||||
* * touchMove to shift the map (single finger) or shift and zoom (two fingers).
|
||||
*
|
||||
* Communication with the parent `<LiveMap>` is done through the transformMap {@link components/map/LiveMap!Transformation} property.
|
||||
*/
|
||||
export const Handlers: react.FC<HandlersProperties> = (
|
||||
props: HandlersProperties
|
||||
) => {
|
||||
const genericHandler = (event: any) => {
|
||||
// console.log(`Log - Event: ${event.type}`);
|
||||
// if (event.clientX !== undefined) {
|
||||
// console.log(
|
||||
// `Mouse : ${event.clientX}, ${event.clientY}, target: ${event.target}`
|
||||
// );
|
||||
// console.log(
|
||||
// `mouseState: ' ${JSON.stringify(mouseState)} (+${
|
||||
// Date.now() - mouseState.timestamp
|
||||
// }ms) `
|
||||
// );
|
||||
// return;
|
||||
//}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Mouse handlers
|
||||
*
|
||||
*/
|
||||
const initialMouseState = {
|
||||
down: false,
|
||||
starting: { x: -1, y: -1 },
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const mouseState = useRef(initialMouseState);
|
||||
|
||||
const mouseLeaveHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
mouseState.current = initialMouseState;
|
||||
};
|
||||
|
||||
const mouseDownHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
mouseState.current = {
|
||||
down: true,
|
||||
starting: { x: event.clientX, y: event.clientY },
|
||||
timestamp: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const mouseUpHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
mouseState.current = initialMouseState;
|
||||
};
|
||||
|
||||
const mouseMoveHandler = (event: any) => {
|
||||
if (
|
||||
mouseState.current.down &&
|
||||
Date.now() - mouseState.current.timestamp >
|
||||
handlersConfig.mouseMoveThrottleDelay /* &&
|
||||
(event.clientX - mouseState.current.starting.x) ** 2 +
|
||||
(event.clientY - mouseState.current.starting.y) ** 2 >
|
||||
100 */
|
||||
) {
|
||||
genericHandler(event);
|
||||
if (mouseState.current.down) {
|
||||
props.transformMap({
|
||||
deltaShift: {
|
||||
x: event.clientX - mouseState.current.starting.x,
|
||||
y: event.clientY - mouseState.current.starting.y,
|
||||
},
|
||||
deltaZoom: null,
|
||||
zoomCenter: null,
|
||||
});
|
||||
mouseState.current = {
|
||||
down: true,
|
||||
starting: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Double click
|
||||
*
|
||||
*/
|
||||
|
||||
const doubleClickHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
props.transformMap({
|
||||
deltaShift: null,
|
||||
deltaZoom: Math.SQRT2,
|
||||
zoomCenter: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Wheel handler
|
||||
*
|
||||
*/
|
||||
|
||||
const initialWheelState = {
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const wheelState = useRef(initialWheelState);
|
||||
|
||||
const wheelEventHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
if (
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_PIXEL &&
|
||||
Date.now() - wheelState.current.timestamp >
|
||||
handlersConfig.wheelThrottleDelay
|
||||
) {
|
||||
props.transformMap({
|
||||
deltaShift: null,
|
||||
deltaZoom: event.deltaY < 0 ? Math.SQRT2 : Math.SQRT1_2,
|
||||
zoomCenter: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
});
|
||||
wheelState.current = {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Touch handlers
|
||||
*
|
||||
*/
|
||||
|
||||
interface TouchState {
|
||||
state: 'up' | 'pointer' | 'double';
|
||||
touches: Point[];
|
||||
distance: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const initialTouchState: TouchState = {
|
||||
state: 'up',
|
||||
touches: [],
|
||||
distance: -1,
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
const touchState = useRef<TouchState>(initialTouchState);
|
||||
|
||||
const touchCancelHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
touchState.current = initialTouchState;
|
||||
};
|
||||
|
||||
const touchEndHandler = touchCancelHandler;
|
||||
|
||||
const touchStartHandler = (event: any) => {
|
||||
genericHandler(event);
|
||||
if (event.touches.length === 2) {
|
||||
touchState.current = {
|
||||
state: 'double',
|
||||
touches: [
|
||||
{ x: event.touches[0].screenX, y: event.touches[0].screenY },
|
||||
{ x: event.touches[1].screenX, y: event.touches[1].screenY },
|
||||
],
|
||||
distance: Math.sqrt(
|
||||
(event.touches[0].screenX - event.touches[1].screenX) ** 2 +
|
||||
(event.touches[0].screenY - event.touches[1].screenY) ** 2
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} else if (event.touches.length === 1) {
|
||||
touchState.current = {
|
||||
state: 'pointer',
|
||||
touches: [{ x: event.touches[0].screenX, y: event.touches[0].screenY }],
|
||||
timestamp: Date.now(),
|
||||
distance: -1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const touchMoveHandler = (event: any) => {
|
||||
if (
|
||||
touchState.current.state === 'double' &&
|
||||
Date.now() - touchState.current.timestamp >
|
||||
handlersConfig.doubleTouchMoveThrottleDelay
|
||||
) {
|
||||
if (event.touches.length === 2) {
|
||||
genericHandler(event);
|
||||
const newDistance = Math.sqrt(
|
||||
(event.touches[0].screenX - event.touches[1].screenX) ** 2 +
|
||||
(event.touches[0].screenY - event.touches[1].screenY) ** 2
|
||||
);
|
||||
const factor = newDistance / touchState.current.distance;
|
||||
// console.log(`+++++++++ ZOOM Factor is ${factor} ++++++++++`);
|
||||
const previousCenter = {
|
||||
x:
|
||||
(touchState.current.touches[0].x +
|
||||
touchState.current.touches[1].x) /
|
||||
2,
|
||||
y:
|
||||
(touchState.current.touches[0].y +
|
||||
touchState.current.touches[1].y) /
|
||||
2,
|
||||
};
|
||||
const currentCenter = {
|
||||
x: (event.touches[0].screenX + event.touches[1].screenX) / 2,
|
||||
y: (event.touches[0].screenY + event.touches[1].screenY) / 2,
|
||||
};
|
||||
props.transformMap({
|
||||
deltaShift: {
|
||||
x: currentCenter.x - previousCenter.x,
|
||||
y: currentCenter.y - previousCenter.y,
|
||||
},
|
||||
deltaZoom: factor,
|
||||
zoomCenter: {
|
||||
x: (currentCenter.x + previousCenter.x) / 2,
|
||||
y: (currentCenter.y + previousCenter.y) / 2,
|
||||
},
|
||||
});
|
||||
touchState.current = {
|
||||
state: 'double',
|
||||
touches: [
|
||||
{ x: event.touches[0].screenX, y: event.touches[0].screenY },
|
||||
{ x: event.touches[1].screenX, y: event.touches[1].screenY },
|
||||
],
|
||||
distance: newDistance,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
touchState.current.state === 'pointer' &&
|
||||
Date.now() - touchState.current.timestamp >
|
||||
handlersConfig.singleTouchMoveThrottleDelay
|
||||
) {
|
||||
if (event.touches.length === 1) {
|
||||
genericHandler(event);
|
||||
props.transformMap({
|
||||
deltaShift: {
|
||||
x: event.touches[0].screenX - touchState.current.touches[0].x,
|
||||
y: event.touches[0].screenY - touchState.current.touches[0].y,
|
||||
},
|
||||
deltaZoom: null,
|
||||
zoomCenter: null,
|
||||
});
|
||||
touchState.current = {
|
||||
state: 'pointer',
|
||||
touches: [
|
||||
{
|
||||
x: event.touches[0].screenX,
|
||||
y: event.touches[0].screenY,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
distance: -1,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [, setIsOpen] = useAtom(isOpenAtom);
|
||||
|
||||
const contextMenuHandler = (event: any) => {
|
||||
console.log(event);
|
||||
event.preventDefault();
|
||||
console.log(cache.map({ cacheId: 'points' }));
|
||||
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='handler'
|
||||
role='presentation'
|
||||
onMouseDown={mouseDownHandler}
|
||||
onMouseMove={mouseMoveHandler}
|
||||
onMouseUp={mouseUpHandler}
|
||||
onMouseLeave={mouseLeaveHandler}
|
||||
onDoubleClick={doubleClickHandler}
|
||||
onWheel={wheelEventHandler}
|
||||
onTouchEnd={touchEndHandler}
|
||||
onTouchCancel={touchCancelHandler}
|
||||
onTouchStart={touchStartHandler}
|
||||
onTouchMove={touchMoveHandler}
|
||||
onContextMenu={contextMenuHandler}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Handlers;
|
||||
@@ -1,241 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import LayerStack from './LayerStack';
|
||||
import { CoordinateSystem } from './LiveMap';
|
||||
|
||||
describe('The LayerStack component', () => {
|
||||
const coordinateSystem:CoordinateSystem= {
|
||||
shift:{x:0,y:0},
|
||||
zoom:1,
|
||||
}
|
||||
test('generates four empty layers and a populated one', () => {
|
||||
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
|
||||
render(
|
||||
<LayerStack
|
||||
numberOfTiledLayers={5}
|
||||
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
|
||||
coordinateSystem= {coordinateSystem}
|
||||
/>
|
||||
);
|
||||
const svg = screen.getByTestId('layer-stack');
|
||||
expect(svg).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
data-testid="layer-stack"
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<g
|
||||
transform="translate(0, 0) scale(1)"
|
||||
>
|
||||
<g
|
||||
transform="scale(1024) translate(-194.25, -83.25)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(512) translate(-388.5, -166.5)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(64) translate(-3108, -1332)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(128) translate(-1554, -666)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(256) translate(-777, -333)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
});
|
||||
|
||||
test('generates two empty layers and a populated one', () => {
|
||||
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
|
||||
render(
|
||||
<LayerStack
|
||||
numberOfTiledLayers={3}
|
||||
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
|
||||
coordinateSystem= {coordinateSystem}
|
||||
/>
|
||||
);
|
||||
const svg = screen.getByTestId('layer-stack');
|
||||
expect(svg).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
data-testid="layer-stack"
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<g
|
||||
transform="translate(0, 0) scale(1)"
|
||||
>
|
||||
<g
|
||||
transform="scale(512) translate(-388.5, -166.5)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(128) translate(-1554, -666)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(256) translate(-777, -333)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
});
|
||||
|
||||
/* test('populates a new layer when zoomed in', () => {
|
||||
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
|
||||
render(
|
||||
<LayerStack
|
||||
numberOfTiledLayers={3}
|
||||
keyObject={{ provider: 'fake', zoomLevel: 9, x: 777, y: 333 }}
|
||||
coordinateSystem= {coordinateSystem}
|
||||
/>
|
||||
);
|
||||
const { result } = renderHook(() => [
|
||||
useAtom(coordinateSystemAtom),
|
||||
useAtom(relativeCoordinateSystemAtom),
|
||||
]);
|
||||
act(() => {
|
||||
result.current[0][1]({
|
||||
shift: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
zoom: 1,
|
||||
} as any);
|
||||
result.current[1][1]({
|
||||
deltaShift: null,
|
||||
zoomCenter: { x: 0, y: 0 },
|
||||
deltaZoom: 2,
|
||||
} as any);
|
||||
});
|
||||
const svg = screen.getByTestId('layer-stack');
|
||||
expect(svg).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
data-testid="layer-stack"
|
||||
height="768"
|
||||
width="1024"
|
||||
>
|
||||
<g
|
||||
transform="translate(0, 0) scale(2)"
|
||||
>
|
||||
<g
|
||||
transform="scale(128) translate(-388.5, -166.5)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(512) translate(-1554, -666)"
|
||||
/>
|
||||
<g
|
||||
transform="scale(256) translate(-777, -333)"
|
||||
>
|
||||
<g
|
||||
transform="translate(777, 333)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/265/333.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(778, 333)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/266/333.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(779, 333)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/267/333.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(780, 333)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/268/333.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(777, 334)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/265/334.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(778, 334)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/266/334.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(779, 334)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/267/334.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(780, 334)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/268/334.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(777, 335)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/265/335.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(778, 335)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/266/335.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(779, 335)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/267/335.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(780, 335)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/9/268/335.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
}); */
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import react, {
|
||||
cloneElement,
|
||||
JSXElementConstructor,
|
||||
ReactComponentElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { Rectangle, TileKeyObject } from './types';
|
||||
|
||||
import { CoordinateSystem } from './LiveMap';
|
||||
import { range } from 'lodash';
|
||||
import tileUri from './uris';
|
||||
import TiledLayer from './TiledLayer';
|
||||
import { ReactComponentOrElement } from '@ionic/react';
|
||||
import { KeyObject } from 'crypto';
|
||||
import useViewport from './use-viewport';
|
||||
|
||||
export interface LayerStackProperties {
|
||||
/**
|
||||
* A key identifying the top left tile
|
||||
*/
|
||||
keyObject: TileKeyObject;
|
||||
/**
|
||||
* Number of {@link components/map/TiledLayer!TiledLayer}.
|
||||
*/
|
||||
numberOfTiledLayers?: number;
|
||||
/**
|
||||
* The coordinates system
|
||||
*/
|
||||
coordinateSystem: CoordinateSystem;
|
||||
/** Slippy graphics are non scalable SVG snippets defined by geo locations */
|
||||
slippyGraphics?: ReactElement<any, string | JSXElementConstructor<any>>[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns A stack of layers embedded in an SVG element
|
||||
*
|
||||
* This component does the conversion between the {@link components/map/LiveMap!CoordinateSystem} and
|
||||
* the `<`{@link components/map/TiledLayer!TiledLayer}`>`
|
||||
* components which units are in tiles.
|
||||
*
|
||||
* When more then one {@link components/map/TiledLayer!TiledLayer} is required, the tiled layer identified by the keyObject is considered active and new tiles are added
|
||||
* as needed and the other layers are used as backups while the tiles are loading.
|
||||
*
|
||||
*/
|
||||
export const LayerStack: react.FC<LayerStackProperties> = (
|
||||
props: LayerStackProperties
|
||||
) => {
|
||||
console.log(
|
||||
`LayerStack rendering, coordinateSystem: ${JSON.stringify(
|
||||
props.coordinateSystem
|
||||
)}, slippyGraphics: ${props.slippyGraphics}`
|
||||
);
|
||||
const g = useRef<SVGGElement>(null);
|
||||
|
||||
const viewPort = useViewport({
|
||||
coordinateSystem: props.coordinateSystem,
|
||||
keyObject: props.keyObject,
|
||||
svgElement: g,
|
||||
});
|
||||
|
||||
const numberOfTiledLayers =
|
||||
props.numberOfTiledLayers === undefined ? 1 : props.numberOfTiledLayers;
|
||||
|
||||
const activeTiledLayer = Math.floor(numberOfTiledLayers / 2);
|
||||
|
||||
const getTiledLayer = (i: number, viewPort?: Rectangle) => {
|
||||
const relativeZoomLevel = i - activeTiledLayer;
|
||||
const zoom = 2 ** relativeZoomLevel;
|
||||
const origin = {
|
||||
x: props.keyObject.x * zoom,
|
||||
y: props.keyObject.y * zoom,
|
||||
};
|
||||
const keyObject = {
|
||||
provider: props.keyObject.provider,
|
||||
zoomLevel: props.keyObject.zoomLevel + relativeZoomLevel,
|
||||
x: Math.floor(origin.x),
|
||||
y: Math.floor(origin.y),
|
||||
};
|
||||
const shift = {
|
||||
x: -origin.x,
|
||||
y: -origin.y,
|
||||
};
|
||||
const key = tileUri({
|
||||
provider: keyObject.provider,
|
||||
zoomLevel: keyObject.zoomLevel,
|
||||
});
|
||||
|
||||
return (
|
||||
<TiledLayer
|
||||
key={key}
|
||||
keyObject={keyObject}
|
||||
shift={shift}
|
||||
zoom={256 / zoom}
|
||||
coordinateSystem={props.coordinateSystem}
|
||||
viewPort={viewPort}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// console.log(`tiledLayers: ${JSON.stringify(tiledLayers)}`);
|
||||
|
||||
return (
|
||||
<svg width='100%' height='100%' data-testid='layer-stack'>
|
||||
<g
|
||||
transform={`translate(${props.coordinateSystem.shift.x}, ${props.coordinateSystem.shift.y}) scale(${props.coordinateSystem.zoom})`}
|
||||
key='tiles'
|
||||
ref={g}
|
||||
>
|
||||
{
|
||||
// Tiled layers with less detail
|
||||
range(0, activeTiledLayer).map((index) => getTiledLayer(index))
|
||||
}
|
||||
{
|
||||
// Tiled layers with more details
|
||||
range(numberOfTiledLayers - 1, activeTiledLayer, -1).map((index) =>
|
||||
getTiledLayer(index)
|
||||
)
|
||||
}
|
||||
{
|
||||
// And the active one
|
||||
getTiledLayer(activeTiledLayer, viewPort)
|
||||
}
|
||||
{props.slippyGraphics !== undefined ? (
|
||||
// Slippy graphics (if needed)
|
||||
<>
|
||||
{props.slippyGraphics.map((slippyGraphic) =>
|
||||
cloneElement(slippyGraphic, {
|
||||
keyObject: props.keyObject,
|
||||
zoom: props.coordinateSystem.zoom,
|
||||
viewPort: viewPort,
|
||||
})
|
||||
)}
|
||||
{console.log('LayerStack: adding slippyGraphics')},
|
||||
</>
|
||||
) : null}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayerStack;
|
||||
@@ -1,155 +0,0 @@
|
||||
import react, {
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useDimensions from 'react-cool-dimensions';
|
||||
|
||||
import { MapScope, Point } from './types';
|
||||
import Map from './Map';
|
||||
import Handlers from './Handlers';
|
||||
import { tileProviders } from './tile-providers';
|
||||
import { lon2tile, lat2tile, tile2lat, tile2long } from '../../lib/geo';
|
||||
|
||||
/**
|
||||
* Definition of a coordinate system
|
||||
*
|
||||
* The coordinate system is shifted and zoomed (from the viewport origin)
|
||||
*
|
||||
*/
|
||||
export interface CoordinateSystem {
|
||||
/** Zoom relative to the origin */
|
||||
zoom: number;
|
||||
/** Origin's shift (in pixels) */
|
||||
shift: Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Description of coordinates system transformation
|
||||
*/
|
||||
export interface Transformation {
|
||||
/** New translation to apply */
|
||||
deltaShift: Point | null;
|
||||
/** Zoom factor to apply */
|
||||
deltaZoom: number | null;
|
||||
/** Center of the new zoom to apply */
|
||||
zoomCenter: Point | null;
|
||||
}
|
||||
|
||||
export interface LiveMapProperties {
|
||||
/** The initial map's scope */
|
||||
scope: MapScope;
|
||||
/** The number of tiled layers (default to 1) */
|
||||
numberOfTiledLayers?: number;
|
||||
/** If provided, a function to call when the scope is updated. */
|
||||
setScope?: (scope: MapScope) => void;
|
||||
/** Slippy graphics are non scalable SVG snippets defined by geo locations */
|
||||
slippyGraphics?: ReactElement<any, string | JSXElementConstructor<any>>[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns A `<LiveMap>` component.
|
||||
*
|
||||
* A `<LiveMap>` is a wrapper around a {@link components/map/Map!Map} component which updates the `<Map>`'s scope according to user's mouse, wheel and touch events.
|
||||
*
|
||||
* To do so, `<LiveMap>` embeds a `<Map>` component together with a {@link components/map/Handlers!Handlers} component which listens to user's event.
|
||||
*
|
||||
* The main task of `<LiveMap>` components is thus to translate {@link Transformation}s delivered by `<Handler>` in pixels into geographical coordinates.
|
||||
*/
|
||||
export const LiveMap: react.FC<LiveMapProperties> = (
|
||||
props: LiveMapProperties
|
||||
) => {
|
||||
const [scope, setScope] = useState(props.scope);
|
||||
useEffect(() => {
|
||||
setScope(props.scope);
|
||||
}, [props.scope]);
|
||||
console.log(
|
||||
`LiveMap rendering, scope: ${JSON.stringify(scope)}, slippyGraphics: ${
|
||||
props.slippyGraphics
|
||||
}`
|
||||
);
|
||||
const { observe, width, height } = useDimensions<HTMLDivElement>();
|
||||
const transform = (t: Transformation) => {
|
||||
const deltaZoom = t.deltaZoom === null ? 1 : t.deltaZoom;
|
||||
const deltaZoomLevel = Math.log2(deltaZoom);
|
||||
|
||||
const tileProvider = tileProviders[scope.tileProvider];
|
||||
|
||||
const tilesZoom = Math.min(
|
||||
Math.max(Math.round(scope.zoom), tileProvider.minZoom),
|
||||
tileProvider.maxZoom
|
||||
);
|
||||
const softZoom = scope.zoom - tilesZoom;
|
||||
const relativeScale = 2 ** softZoom;
|
||||
const visibleTileSize = tileProvider.tileSize * relativeScale;
|
||||
|
||||
// Values in pixels
|
||||
const actualDeltaShift =
|
||||
t.deltaShift === null ? { x: 0, y: 0 } : t.deltaShift;
|
||||
const actualZoomCenter =
|
||||
t.zoomCenter === null ? { x: 0, y: 0 } : t.zoomCenter;
|
||||
|
||||
// Values in tiles (for the current zoom level)
|
||||
const actualDeltaShiftTiles = {
|
||||
x: actualDeltaShift.x / visibleTileSize,
|
||||
y: actualDeltaShift.y / visibleTileSize,
|
||||
};
|
||||
const actualZoomCenterTiles = {
|
||||
x: actualZoomCenter.x / visibleTileSize,
|
||||
y: actualZoomCenter.y / visibleTileSize,
|
||||
};
|
||||
const tilesCenter: Point = {
|
||||
x: lon2tile(scope.center.lon, tilesZoom),
|
||||
y: lat2tile(scope.center.lat, tilesZoom),
|
||||
};
|
||||
|
||||
const newTilesCenter = {
|
||||
x:
|
||||
tilesCenter.x -
|
||||
actualDeltaShiftTiles.x -
|
||||
(width / 2 / visibleTileSize - actualZoomCenterTiles.x) *
|
||||
(deltaZoom - 1),
|
||||
y:
|
||||
tilesCenter.y -
|
||||
actualDeltaShiftTiles.y -
|
||||
(height / 2 / visibleTileSize - actualZoomCenterTiles.y) *
|
||||
(deltaZoom - 1),
|
||||
};
|
||||
|
||||
const newScope: MapScope = {
|
||||
center: {
|
||||
lat: tile2lat(newTilesCenter.y, tilesZoom),
|
||||
lon: tile2long(newTilesCenter.x, tilesZoom),
|
||||
},
|
||||
zoom: deltaZoomLevel + scope.zoom,
|
||||
tileProvider: scope.tileProvider,
|
||||
};
|
||||
// console.log(
|
||||
// `LiveMap transform: ${JSON.stringify(t)}, ${JSON.stringify(
|
||||
// scope
|
||||
// )} -> ${JSON.stringify(newScope)}, delta lat: ${
|
||||
// newScope.center.lat - scope.center.lat
|
||||
// }, delta lon: ${newScope.center.lon - scope.center.lon}`
|
||||
// );
|
||||
setScope(newScope);
|
||||
if (props.setScope !== undefined) {
|
||||
props.setScope(newScope);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }} ref={observe}>
|
||||
<Handlers transformMap={transform} />
|
||||
<Map
|
||||
scope={scope}
|
||||
numberOfTiledLayers={props.numberOfTiledLayers}
|
||||
slippyGraphics={props.slippyGraphics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveMap;
|
||||
@@ -1,77 +0,0 @@
|
||||
import react, { JSXElementConstructor, ReactElement, ReactNode } from 'react';
|
||||
import useDimensions from 'react-cool-dimensions';
|
||||
|
||||
import { Point, MapScope } from './types';
|
||||
import Marker from './Marker';
|
||||
|
||||
import LayerStack from './LayerStack';
|
||||
import { tileProviders } from './tile-providers';
|
||||
import { lon2tile, lat2tile } from '../../lib/geo';
|
||||
|
||||
export interface MapProperties {
|
||||
scope: MapScope;
|
||||
numberOfTiledLayers?: number;
|
||||
/** Slippy graphics are non scalable SVG snippets defined by geo locations */
|
||||
slippyGraphics?: ReactElement<any, string | JSXElementConstructor<any>>[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns A `<Map>` component
|
||||
*
|
||||
* `<Map>` components display the map specified by their {@link MapProperties}'s scope.
|
||||
*
|
||||
* They can be driven by {@link components/map/LiveMap!LiveMap} component to react to user's event.
|
||||
*
|
||||
*/
|
||||
export const Map: react.FC<MapProperties> = (props: MapProperties) => {
|
||||
console.log(
|
||||
`Map rendering, scope: ${JSON.stringify(props.scope)}, slippyGraphics: ${
|
||||
props.slippyGraphics
|
||||
}`
|
||||
);
|
||||
|
||||
const { observe, width, height } = useDimensions<HTMLDivElement>();
|
||||
|
||||
const tileProvider = tileProviders[props.scope.tileProvider];
|
||||
|
||||
const tilesZoom = Math.min(
|
||||
Math.max(Math.round(props.scope.zoom), tileProvider.minZoom),
|
||||
tileProvider.maxZoom
|
||||
);
|
||||
const tilesCenter: Point = {
|
||||
x: lon2tile(props.scope.center.lon, tilesZoom),
|
||||
y: lat2tile(props.scope.center.lat, tilesZoom),
|
||||
};
|
||||
const softZoom = props.scope.zoom - tilesZoom;
|
||||
const relativeScale = 2 ** softZoom;
|
||||
const visibleTileSize = tileProvider.tileSize * relativeScale;
|
||||
const nbTilesLeft = width / 2 / visibleTileSize;
|
||||
const nbTilesTop = height / 2 / visibleTileSize;
|
||||
const firstTileLeft = Math.floor(tilesCenter.x - nbTilesLeft);
|
||||
const firstTileTop = Math.floor(tilesCenter.y - nbTilesTop);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }} ref={observe}>
|
||||
<LayerStack
|
||||
numberOfTiledLayers={props.numberOfTiledLayers}
|
||||
keyObject={{
|
||||
provider: props.scope.tileProvider,
|
||||
zoomLevel: tilesZoom,
|
||||
x: firstTileLeft,
|
||||
y: firstTileTop,
|
||||
}}
|
||||
coordinateSystem={{
|
||||
shift: {
|
||||
x: -((tilesCenter.x - nbTilesLeft) % 1) * visibleTileSize,
|
||||
y: -((tilesCenter.y - nbTilesTop) % 1) * visibleTileSize,
|
||||
},
|
||||
zoom: relativeScale,
|
||||
}}
|
||||
slippyGraphics={props.slippyGraphics}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
||||
@@ -1,72 +0,0 @@
|
||||
import react, { ReactNode, useEffect } from 'react';
|
||||
import { lat2tile, lon2tile } from '../../lib/geo';
|
||||
import cache from '../../lib/cache';
|
||||
import { geoPoint, Rectangle, TileKeyObject } from './types';
|
||||
|
||||
export interface MarkerProperties {
|
||||
id: string;
|
||||
coordinates: geoPoint;
|
||||
icon: ReactNode;
|
||||
keyObject?: TileKeyObject;
|
||||
zoom?: number;
|
||||
viewPort?: Rectangle;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Marker: react.FC<MarkerProperties> = (props: MarkerProperties) => {
|
||||
console.log(`Rendering Marker`);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`Marker onMount, id:${props.id}`);
|
||||
return () => {
|
||||
console.log(`Marker onUnmount, id:${props.id}`);
|
||||
cache.delete({ cacheId: 'points', key: props.id });
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (
|
||||
props.keyObject === undefined ||
|
||||
props.zoom === undefined ||
|
||||
props.viewPort === undefined
|
||||
) {
|
||||
console.log(`Marker props undefined: ${JSON.stringify(props)} `);
|
||||
return null;
|
||||
}
|
||||
|
||||
const x0 = lon2tile(props.coordinates.lon, 0);
|
||||
const y0 = lat2tile(props.coordinates.lat, 0);
|
||||
const x = x0 * 2 ** props.keyObject.zoomLevel;
|
||||
const y = y0 * 2 ** props.keyObject.zoomLevel;
|
||||
if (
|
||||
x < props.viewPort.topLeft.x ||
|
||||
x > props.viewPort.bottomRight.x + 1 ||
|
||||
y < props.viewPort.topLeft.y ||
|
||||
y > props.viewPort.bottomRight.y + 1
|
||||
) {
|
||||
cache.delete({ cacheId: 'points', key: props.id });
|
||||
console.log(
|
||||
`Marker ${x}, ${y} out of viewport: ${JSON.stringify(props.viewPort)} `
|
||||
);
|
||||
return null;
|
||||
}
|
||||
cache.set({
|
||||
cacheId: 'points',
|
||||
key: props.id,
|
||||
value: {
|
||||
coordinates: props.coordinates,
|
||||
point: { x: x0, y: y0 },
|
||||
},
|
||||
});
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${(x - props.keyObject.x) * 256}, ${
|
||||
(y - props.keyObject.y) * 256
|
||||
}) scale(${1 / props.zoom})`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.icon}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default Marker;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Tile from './Tile';
|
||||
import { TileKeyObject } from './types';
|
||||
|
||||
describe('The Tile component ', () => {
|
||||
const keyObject: TileKeyObject = {
|
||||
provider: 'fake',
|
||||
zoomLevel: 10,
|
||||
x: 1,
|
||||
y: 2,
|
||||
};
|
||||
test('Is initially empty', () => {
|
||||
const { baseElement } = render(
|
||||
<svg>
|
||||
<Tile keyObject={keyObject} delay={10000} />
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(1, 2)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
test('Gets its image immediately with a fake URL', () => {
|
||||
const { baseElement } = render(
|
||||
<svg>
|
||||
<Tile keyObject={keyObject} />
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(1, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import react, { memo, useEffect, useRef } from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import { TileKeyObject } from './types';
|
||||
import { getTileUrl } from './tile-providers';
|
||||
|
||||
export interface TileProperties {
|
||||
/** The image's source URL */
|
||||
keyObject: TileKeyObject;
|
||||
/** A delay to add (for test/debug purposes) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns A tile
|
||||
*
|
||||
* Tile components are containers for images.
|
||||
*
|
||||
* They return an empty `<g/>` element immediately so that the rendering can proceed
|
||||
* and append an `<image/>` element whenever the image is loaded.
|
||||
*
|
||||
* They are designed to be part of {@link components/map/TiledLayer!TiledLayer} components in SVG `<g/>` elements
|
||||
* in which the unit is the tile size. In this coordinate system their size is thus always equal to 1.
|
||||
*/
|
||||
export const Tile: react.FC<TileProperties> = memo((props: TileProperties) => {
|
||||
const g = useRef<SVGGElement>(null);
|
||||
|
||||
const timeout = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
// console.log(`Rendering tile: ${JSON.stringify(props)}`);
|
||||
useEffect(() => {
|
||||
const loadImage = async () => {
|
||||
// console.log(`Pre loading: ${props.href}`);
|
||||
const href = getTileUrl(props.keyObject);
|
||||
const image = new Image(1, 1);
|
||||
image.loading = 'eager';
|
||||
// @ts-ignore
|
||||
image.setAttribute('href', href);
|
||||
if (!image.complete) {
|
||||
await image.decode();
|
||||
}
|
||||
if (props.delay !== undefined) {
|
||||
await timeout(props.delay);
|
||||
}
|
||||
const svgImage = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'image'
|
||||
) as unknown as SVGImageElement;
|
||||
svgImage.setAttribute('width', '1');
|
||||
svgImage.setAttribute('height', '1');
|
||||
// @ts-ignore
|
||||
svgImage.setAttribute('href', href);
|
||||
g.current?.replaceChildren(svgImage);
|
||||
};
|
||||
loadImage();
|
||||
}, [props.keyObject]);
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={g}
|
||||
transform={`translate(${props.keyObject.x}, ${props.keyObject.y})`}
|
||||
/>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
export default Tile;
|
||||
@@ -1,309 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import TileSet from './TileSet';
|
||||
|
||||
describe('The TiledLayer component ', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.cacheForTileSet = new Map();
|
||||
});
|
||||
|
||||
test('exposes the tiles needed per its viewport', () => {
|
||||
const { baseElement } = render(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 5, y: 8 }}
|
||||
viewPort={{ topLeft: { x: 1, y: 2 }, bottomRight: { x: 3, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(1, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(2, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/2/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(3, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/3/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
|
||||
test('adds more tiles when its viewport is updated without removing the previous ones', () => {
|
||||
const { baseElement, rerender } = render(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 5, y: 8 }}
|
||||
viewPort={{ topLeft: { x: 1, y: 2 }, bottomRight: { x: 3, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
rerender(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 5, y: 8 }}
|
||||
viewPort={{ topLeft: { x: 5, y: 0 }, bottomRight: { x: 5, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(1, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(2, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/2/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(3, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/3/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 0)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/0.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/1.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
test('is not reinitialized if its key isObject updated', () => {
|
||||
const { baseElement, rerender } = render(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 5, y: 8 }}
|
||||
viewPort={{ topLeft: { x: 1, y: 2 }, bottomRight: { x: 3, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
rerender(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 4, y: 10 }}
|
||||
viewPort={{ topLeft: { x: 5, y: 0 }, bottomRight: { x: 5, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(1, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(2, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/2/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(3, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/3/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 0)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/0.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/1.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(5, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/5/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
test('Also works with negative coordinates', () => {
|
||||
const { baseElement } = render(
|
||||
<svg>
|
||||
<TileSet
|
||||
keyObject={{ provider: 'fake', zoomLevel: 10, x: 5, y: 8 }}
|
||||
viewPort={{ topLeft: { x: -3, y: -1 }, bottomRight: { x: -2, y: 2 } }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
// screen.debug();
|
||||
expect(baseElement).toMatchInlineSnapshot(`
|
||||
<body>
|
||||
<div>
|
||||
<svg>
|
||||
<g
|
||||
transform="translate(-3, -1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1021/1023.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-2, -1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1022/1023.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-3, 0)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1021/0.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-2, 0)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1022/0.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-3, 1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1021/1.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-2, 1)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1022/1.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-3, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1021/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
transform="translate(-2, 2)"
|
||||
>
|
||||
<image
|
||||
height="1"
|
||||
href="https://fakeurl/10/1022/2.png"
|
||||
width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</body>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import react, { memo } from 'react';
|
||||
import { isEqual, range } from 'lodash';
|
||||
|
||||
import { Rectangle, TileKeyObject } from './types';
|
||||
import tileUri from './uris';
|
||||
import Tile from './Tile';
|
||||
import { tileSetConfig } from './config';
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
export const thisIsAModule = true;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
||||
declare global {
|
||||
var cacheForTileSet: any;
|
||||
}
|
||||
|
||||
//export {};
|
||||
|
||||
globalThis.cacheForTileSet = new Map();
|
||||
|
||||
export interface TileSetProperties {
|
||||
/** A partial Tile key object specifying the provider and zoom level */
|
||||
keyObject: TileKeyObject;
|
||||
/** The current viewport expressed in tiles coordinates */
|
||||
viewPort?: Rectangle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lazily loaded set of tiles.
|
||||
*
|
||||
* This component is rather dumb and is mainly a sparse array of tiles.
|
||||
*
|
||||
* New tiles are added to the array when the viewport is updated and they stay in the array until
|
||||
* the component is destroyed.
|
||||
*
|
||||
* This component has no need to know the number nor the size of its tiles: tiles can be added when needed and
|
||||
* its unit is the tile size (the parent component needs to transform its enclosing SVG group to adapt its units)
|
||||
*
|
||||
* The `globalThis.cacheForTiledLayer` global variable is used as a cache to store tiles without being subject
|
||||
* to re-initialization when components are unmounted/remounted.
|
||||
*
|
||||
* This cache is a map of map, the first key identifying the `<TiledLayer` and the second one for `<Tile>`s.
|
||||
*
|
||||
* Idea stolen [on the web](https://dev.to/tiagof/react-re-mounting-vs-re-rendering-lnh)
|
||||
*
|
||||
*
|
||||
*/
|
||||
export const TileSet: react.FC<TileSetProperties> = memo(
|
||||
(props: TileSetProperties) => {
|
||||
// console.log(`Rendering TiledLayer: ${JSON.stringify(props)}`);
|
||||
|
||||
const key = tileUri({
|
||||
provider: props.keyObject.provider,
|
||||
zoomLevel: props.keyObject.zoomLevel,
|
||||
});
|
||||
|
||||
const tiles: any = globalThis.cacheForTileSet.get(key) ?? new Map();
|
||||
if (props.viewPort !== undefined) {
|
||||
range(props.viewPort.topLeft.y, props.viewPort.bottomRight.y + 1).forEach(
|
||||
(row) => {
|
||||
range(
|
||||
props.viewPort!.topLeft.x,
|
||||
props.viewPort!.bottomRight.x + 1
|
||||
).forEach((col) => {
|
||||
const keyObject = {
|
||||
provider: props.keyObject.provider,
|
||||
zoomLevel: props.keyObject.zoomLevel,
|
||||
x: props.keyObject.x + col,
|
||||
y: props.keyObject.y + row,
|
||||
};
|
||||
const key = tileUri(keyObject);
|
||||
if (!tiles.has(key)) {
|
||||
tiles.set(
|
||||
key,
|
||||
<Tile
|
||||
key={key}
|
||||
keyObject={{
|
||||
provider: props.keyObject.provider,
|
||||
zoomLevel: props.keyObject.zoomLevel,
|
||||
x: col,
|
||||
y: row,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const tile = tiles.get(key);
|
||||
tiles.delete(key);
|
||||
tiles.set(key, tile);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
if (tiles.size > tileSetConfig.cacheSizePerLayer) {
|
||||
const oldestTileKeys = [...tiles.keys()];
|
||||
oldestTileKeys.splice(-tileSetConfig.cacheSizePerLayer);
|
||||
oldestTileKeys.forEach((tileKey) => {
|
||||
tiles.delete(tileKey);
|
||||
});
|
||||
}
|
||||
if (globalThis.cacheForTileSet.has(key)) {
|
||||
globalThis.cacheForTileSet.delete(key);
|
||||
}
|
||||
globalThis.cacheForTileSet.set(key, tiles);
|
||||
if (globalThis.cacheForTileSet > tileSetConfig.numberOfCachedLayers) {
|
||||
const oldestCachedLayerKeys = [...globalThis.cacheForTileSet.keys()];
|
||||
oldestCachedLayerKeys.slice(-tileSetConfig.numberOfCachedLayers);
|
||||
oldestCachedLayerKeys.forEach((cachedLayerKey) => {
|
||||
globalThis.cacheForTileSet.delete(cachedLayerKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return <>{Array.from(tiles.values())}</>;
|
||||
},
|
||||
isEqual
|
||||
);
|
||||
|
||||
export default TileSet;
|
||||
@@ -1,61 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CoordinateSystem } from './LiveMap';
|
||||
import TiledLayer from './TiledLayer';
|
||||
|
||||
describe('The TiledLayer component', () => {
|
||||
const coordinateSystem: CoordinateSystem = {
|
||||
shift: { x: 0, y: 0 },
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.cacheForTileSet = new Map();
|
||||
});
|
||||
|
||||
test('generates an empty layer if inactive', () => {
|
||||
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
|
||||
render(
|
||||
<svg data-testid='tiled-layer'>
|
||||
<TiledLayer
|
||||
keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }}
|
||||
shift={{ x: 0.5, y: 0.25 }}
|
||||
zoom={4}
|
||||
coordinateSystem={coordinateSystem}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const svg = screen.getByTestId('tiled-layer');
|
||||
expect(svg).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
data-testid="tiled-layer"
|
||||
>
|
||||
<g
|
||||
transform="scale(4) translate(0.5, 0.25)"
|
||||
/>
|
||||
</svg>
|
||||
`);
|
||||
});
|
||||
test('generates a populated layer if active', () => {
|
||||
// const { result } = renderHook(() => useAtom(tiledLayersAtom));
|
||||
render(
|
||||
<svg data-testid='tiled-layer'>
|
||||
<TiledLayer
|
||||
keyObject={{ provider: 'fake', zoomLevel: 5, x: 2, y: 3 }}
|
||||
shift={{ x: 0, y: 0 }}
|
||||
zoom={1}
|
||||
coordinateSystem={coordinateSystem}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const svg = screen.getByTestId('tiled-layer');
|
||||
expect(svg).toMatchInlineSnapshot(`
|
||||
<svg
|
||||
data-testid="tiled-layer"
|
||||
>
|
||||
<g
|
||||
transform="scale(1) translate(0, 0)"
|
||||
/>
|
||||
</svg>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import react, {
|
||||
cloneElement,
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import TileSet from './TileSet';
|
||||
import { Point, Rectangle, TileKeyObject } from './types';
|
||||
import { CoordinateSystem } from './LiveMap';
|
||||
import useViewport from './use-viewport';
|
||||
|
||||
export interface TiledLayerProperties {
|
||||
/**
|
||||
* A key identifying the top left tile
|
||||
*/
|
||||
keyObject: TileKeyObject;
|
||||
/**
|
||||
* The translation to apply.
|
||||
*/
|
||||
shift: Point;
|
||||
/**
|
||||
* The zoom to apply. If equal to 256 (the tile size), the layer is considered active and should add tiles which are in its viewport
|
||||
*/
|
||||
zoom: number;
|
||||
/**
|
||||
* The coordinate system
|
||||
*/
|
||||
coordinateSystem: CoordinateSystem;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
viewPort?: Rectangle;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @returns A layer of tiles.
|
||||
* This component wraps a `<TileSet>` in an SVG `<g>` element taking care of the scale and translation.
|
||||
*
|
||||
*/
|
||||
export const TiledLayer: react.FC<TiledLayerProperties> = (
|
||||
props: TiledLayerProperties
|
||||
) => {
|
||||
console.log(
|
||||
`Rendering TiledLayer, zoom: ${props.zoom}, viewPort: ${JSON.stringify(
|
||||
props.viewPort
|
||||
)}`
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<g
|
||||
transform={`scale(${props.zoom}) translate(${props.shift.x}, ${props.shift.y})`}
|
||||
>
|
||||
<TileSet
|
||||
keyObject={{
|
||||
provider: props.keyObject.provider,
|
||||
zoomLevel: props.keyObject.zoomLevel,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}}
|
||||
viewPort={props.viewPort}
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TiledLayer;
|
||||
@@ -1,47 +0,0 @@
|
||||
import react, { startTransition, useEffect, useState } from 'react';
|
||||
import dispatch from '../../workers/dispatcher-main';
|
||||
import { TileKeyObject, Rectangle } from './types';
|
||||
import getUri from '../../lib/ids';
|
||||
import Trkseg from './Trkseg';
|
||||
|
||||
export interface TrkProperties {
|
||||
id: string;
|
||||
keyObject: TileKeyObject;
|
||||
zoom: number;
|
||||
viewPort: Rectangle;
|
||||
}
|
||||
|
||||
export const Trk: react.FC<TrkProperties> = (props: TrkProperties) => {
|
||||
const [trk, setTrk] = useState<any>([]);
|
||||
useEffect(() => {
|
||||
const getTrk = async () => {
|
||||
const trk = await dispatch({
|
||||
action: 'getTrk',
|
||||
params: {
|
||||
id: props.id,
|
||||
},
|
||||
});
|
||||
console.log(`<Trk>, gpx: ${JSON.stringify(trk)}`);
|
||||
startTransition(() => setTrk(trk));
|
||||
};
|
||||
getTrk();
|
||||
}, [props.id]);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{trk
|
||||
.filter((row: any) => row.doc.type === 'trkseg')
|
||||
.map((row: any) => (
|
||||
<Trkseg
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
keyObject={props.keyObject}
|
||||
zoom={props.zoom}
|
||||
viewPort={props.viewPort}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default Trk;
|
||||
@@ -1,15 +0,0 @@
|
||||
.track {
|
||||
fill: transparent;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: rgba(10, 1, 51, 0.8);
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.start {
|
||||
color: green;
|
||||
}
|
||||
.finish {
|
||||
color: red;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import react, { startTransition, useEffect, useState } from 'react';
|
||||
import { lon2tile, lat2tile } from '../../lib/geo';
|
||||
import dispatch from '../../workers/dispatcher-main';
|
||||
import { TileKeyObject, Rectangle, Point, geoPoint } from './types';
|
||||
|
||||
import css from './Trkseg.module.css';
|
||||
import { ReactComponent as Start } from '../../icons/flag-start-b-svgrepo-com.svg';
|
||||
import { ReactComponent as Finish } from '../../icons/flag-finish-b-o-svgrepo-com.svg';
|
||||
import Marker from './Marker';
|
||||
|
||||
export interface TrksegProperties {
|
||||
id: string;
|
||||
keyObject: TileKeyObject;
|
||||
zoom: number;
|
||||
viewPort: Rectangle;
|
||||
}
|
||||
|
||||
export const Trkseg: react.FC<TrksegProperties> = (props: TrksegProperties) => {
|
||||
const [trkseg, setTrkseg] = useState<any>([]);
|
||||
useEffect(() => {
|
||||
const getTrkseg = async () => {
|
||||
const trk = await dispatch({
|
||||
action: 'getTrkseg',
|
||||
params: {
|
||||
id: props.id,
|
||||
},
|
||||
});
|
||||
console.log(`<Trkseg>, gpx: ${JSON.stringify(trk)}`);
|
||||
startTransition(() => setTrkseg(trk));
|
||||
};
|
||||
getTrkseg();
|
||||
}, [props.id]);
|
||||
|
||||
const d = trkseg
|
||||
.slice(1)
|
||||
.reduce((previous: string, current: any, index: number) => {
|
||||
const action = index === 0 ? 'M' : index === 1 ? 'L' : '';
|
||||
const trkpt = current.doc.doc;
|
||||
return `${previous} ${action} ${
|
||||
(lon2tile(trkpt.$.lon, props.keyObject.zoomLevel) - props.keyObject.x) *
|
||||
256
|
||||
}, ${
|
||||
(lat2tile(trkpt.$.lat, props.keyObject.zoomLevel) - props.keyObject.y) *
|
||||
256
|
||||
}`;
|
||||
}, '');
|
||||
|
||||
return (
|
||||
<>
|
||||
{trkseg[1] !== undefined ? (
|
||||
<>
|
||||
<Marker
|
||||
id={`${props.id}/start`}
|
||||
coordinates={trkseg[1].doc.doc.$ as geoPoint}
|
||||
key='start'
|
||||
keyObject={props.keyObject}
|
||||
icon={
|
||||
<g
|
||||
className={css.start}
|
||||
transform=' scale(.5) translate(-50, -100)'
|
||||
>
|
||||
<Start />
|
||||
</g>
|
||||
}
|
||||
viewPort={props.viewPort}
|
||||
zoom={props.zoom}
|
||||
/>
|
||||
|
||||
<path d={d} pointerEvents='none' className={css.track} />
|
||||
<Marker
|
||||
id={`${props.id}/finish`}
|
||||
coordinates={trkseg.at(-1).doc.doc.$ as geoPoint}
|
||||
key='finish'
|
||||
keyObject={props.keyObject}
|
||||
icon={
|
||||
<g
|
||||
className={css.finish}
|
||||
transform=' scale(.5) translate(-50, -100)'
|
||||
>
|
||||
<Finish />
|
||||
</g>
|
||||
}
|
||||
viewPort={props.viewPort}
|
||||
zoom={props.zoom}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Trkseg;
|
||||
@@ -1,16 +0,0 @@
|
||||
/**The handlers configuration */
|
||||
export const handlersConfig = {
|
||||
/**Controls the activity of the mouse mouse event */
|
||||
mouseMoveThrottleDelay: 50,
|
||||
/**Controls the activity of the wheel event */
|
||||
wheelThrottleDelay: 100,
|
||||
/** Controls the activity of the single touch move event */
|
||||
singleTouchMoveThrottleDelay: 50,
|
||||
/** Controls the activity of the double touch move event */
|
||||
doubleTouchMoveThrottleDelay: 100,
|
||||
};
|
||||
|
||||
export const tileSetConfig = {
|
||||
cacheSizePerLayer: 1000,
|
||||
numberOfCachedLayers: 20,
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import { TileKeyObject } from './types';
|
||||
|
||||
export interface TileProvider {
|
||||
name: string;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
tileSize: number;
|
||||
getTileUrl: { (zoom: number, x: number, y: number): string };
|
||||
}
|
||||
|
||||
const getRandomItem = (items: any[]) => {
|
||||
const idx = Math.floor(Math.random() * items.length);
|
||||
return items[idx];
|
||||
};
|
||||
|
||||
const abc = ['a', 'b', 'c'];
|
||||
|
||||
export type TileProviders = {
|
||||
[key: string]: TileProvider;
|
||||
};
|
||||
|
||||
export const tileProviders: TileProviders = {
|
||||
osm: {
|
||||
name: 'Open Street Map',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
tileSize: 256,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://tile.openstreetmap.org/' + zoom + '/' + x + '/' + y + '.png',
|
||||
},
|
||||
osmfr: {
|
||||
name: 'Open Street Map France',
|
||||
minZoom: 0,
|
||||
tileSize: 256,
|
||||
maxZoom: 20,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://' +
|
||||
getRandomItem(abc) +
|
||||
'.tile.openstreetmap.fr/osmfr/' +
|
||||
zoom +
|
||||
'/' +
|
||||
x +
|
||||
'/' +
|
||||
y +
|
||||
'.png',
|
||||
},
|
||||
otm: {
|
||||
name: 'Open Topo Map',
|
||||
minZoom: 2,
|
||||
maxZoom: 17,
|
||||
tileSize: 256,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://' +
|
||||
getRandomItem(abc) +
|
||||
'.tile.opentopomap.org/' +
|
||||
zoom +
|
||||
'/' +
|
||||
x +
|
||||
'/' +
|
||||
y +
|
||||
'.png',
|
||||
},
|
||||
cyclosm: {
|
||||
name: 'CyclOSM',
|
||||
minZoom: 0,
|
||||
maxZoom: 19,
|
||||
tileSize: 256,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://' +
|
||||
getRandomItem(abc) +
|
||||
'.tile-cyclosm.openstreetmap.fr/cyclosm/' +
|
||||
zoom +
|
||||
'/' +
|
||||
x +
|
||||
'/' +
|
||||
y +
|
||||
'.png',
|
||||
},
|
||||
//https://b.tile.openstreetmap.fr/openriverboatmap/20/535762/382966.png
|
||||
openriverboatmap: {
|
||||
name: 'Open River Boat Map',
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
tileSize: 256,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://' +
|
||||
getRandomItem(abc) +
|
||||
'.tile.openstreetmap.fr/openriverboatmap/' +
|
||||
zoom +
|
||||
'/' +
|
||||
x +
|
||||
'/' +
|
||||
y +
|
||||
'.png',
|
||||
},
|
||||
|
||||
// cyclosmlite: {
|
||||
// name: 'CyclOSM lite',
|
||||
// minZoom: 0,
|
||||
// maxZoom: 19,
|
||||
// getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
// 'https://' +
|
||||
// getRandomItem(abc) +
|
||||
// '.tile-cyclosm.openstreetmap.fr/cyclosm-lite/' +
|
||||
// zoom +
|
||||
// '/' +
|
||||
// x +
|
||||
// '/' +
|
||||
// y +
|
||||
// '.png',
|
||||
// },
|
||||
// esrisat: {
|
||||
// name: 'ESRI Satellite',
|
||||
// minZoom: 0,
|
||||
// maxZoom: 19,
|
||||
// getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
// 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/' +
|
||||
// zoom +
|
||||
// '/' +
|
||||
// x +
|
||||
// '/' +
|
||||
// y +
|
||||
// '.jpg',
|
||||
// },
|
||||
|
||||
fake: {
|
||||
name: 'Fake provider',
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
tileSize: 256,
|
||||
getTileUrl: (zoom: number, x: number, y: number) =>
|
||||
'https://fakeurl/' + zoom + '/' + x + '/' + y + '.png',
|
||||
},
|
||||
};
|
||||
|
||||
export const { fake, ...nonFakeTileProviders } = tileProviders;
|
||||
|
||||
const mod = (n: number, m: number) => {
|
||||
const jsMod = n % m;
|
||||
return jsMod >= 0 ? jsMod : jsMod + m;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param keyObject The tile identifier
|
||||
* @returns The `<Tile>`'s URL 'as a string)
|
||||
*/
|
||||
export const getTileUrl = (keyObject: TileKeyObject) => {
|
||||
const nbTiles = 2 ** keyObject.zoomLevel;
|
||||
const x = mod(keyObject.x, nbTiles);
|
||||
const y = mod(keyObject.y, nbTiles);
|
||||
return tileProviders[keyObject.provider].getTileUrl(
|
||||
keyObject.zoomLevel,
|
||||
x,
|
||||
y
|
||||
);
|
||||
};
|
||||
export default getTileUrl;
|
||||
@@ -1,53 +0,0 @@
|
||||
import tileProviders, { TileProviders } from './tile-providers';
|
||||
|
||||
/**
|
||||
* An identifier for tiles (can also be used for tile layers)
|
||||
*/
|
||||
export interface TileKeyObject {
|
||||
/**A tile provider id ('osm', 'otm', ...) */
|
||||
provider: keyof TileProviders;
|
||||
/**The zoom level (integer) */
|
||||
zoomLevel: number;
|
||||
/**The X coordinate (integer)*/
|
||||
x: number;
|
||||
/**The Y coordinate (integer) */
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point identified by its longitude and latitude
|
||||
*/
|
||||
export interface geoPoint {
|
||||
lon: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map scope.
|
||||
*
|
||||
* This object contains what's needed to identify the state of the map
|
||||
*
|
||||
*/
|
||||
export interface MapScope {
|
||||
center: geoPoint;
|
||||
zoom: number;
|
||||
tileProvider: keyof TileProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point
|
||||
*/
|
||||
export interface Point {
|
||||
/**The X coordinate (integer)*/
|
||||
x: number;
|
||||
/**The Y coordinate (integer) */
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rectangle
|
||||
*/
|
||||
export interface Rectangle {
|
||||
topLeft: Point;
|
||||
bottomRight: Point;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import tileUri from './uris';
|
||||
describe('Test that', () => {
|
||||
test('uri generation is working', () => {
|
||||
expect(tileUri({ provider: 'osm', zoomLevel: 16, x: 25, y: 52 })).toEqual(
|
||||
'tile/osm/16/25/52'
|
||||
);
|
||||
});
|
||||
test('uri parsing works', () => {
|
||||
expect(tileUri('tile/otm/5/28/3')).toEqual({
|
||||
provider: 'otm',
|
||||
zoomLevel: 5,
|
||||
x: 28,
|
||||
y: 3,
|
||||
});
|
||||
});
|
||||
test('x and y are optional', () => {
|
||||
expect(tileUri({ provider: 'osm', zoomLevel: 16 })).toEqual('tile/osm/16//');
|
||||
});
|
||||
test('uri parsing works', () => {
|
||||
expect(tileUri('tile/otm/5')).toEqual({
|
||||
provider: 'otm',
|
||||
zoomLevel: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { route } from '../../lib/docuri';
|
||||
|
||||
/**
|
||||
* A [docuri](https://github.com/jo/docuri) route for {@link components/map/types!TileKeyObject}
|
||||
*
|
||||
* TODO: update docuri (or write a wrapper) to support datatyping (and formats).
|
||||
*/
|
||||
export const tileUri = (rte: any) => {
|
||||
const r = route('tile/:provider/:zoomLevel(/:x/:y)')(rte);
|
||||
if (typeof r === 'object') {
|
||||
return r.x === undefined
|
||||
? {
|
||||
provider: r.provider,
|
||||
zoomLevel: parseInt(r.zoomLevel),
|
||||
}
|
||||
: {
|
||||
provider: r.provider,
|
||||
zoomLevel: parseInt(r.zoomLevel),
|
||||
x: parseInt(r.x),
|
||||
y: parseInt(r.y),
|
||||
};
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
export default tileUri;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { CoordinateSystem } from './LiveMap';
|
||||
import { Rectangle, TileKeyObject } from './types';
|
||||
|
||||
const useViewport = (props: {
|
||||
keyObject: TileKeyObject;
|
||||
coordinateSystem: CoordinateSystem;
|
||||
svgElement: RefObject<SVGGElement>;
|
||||
}) => {
|
||||
const { keyObject, coordinateSystem, svgElement } = props;
|
||||
const [viewPort, setViewPort] = useState<Rectangle>();
|
||||
useEffect(() => {
|
||||
if (
|
||||
svgElement.current !== null &&
|
||||
svgElement.current.ownerSVGElement !== null &&
|
||||
svgElement.current.ownerSVGElement.parentElement !== null
|
||||
) {
|
||||
const nearerHTMLParent = svgElement.current.ownerSVGElement.parentElement;
|
||||
setViewPort({
|
||||
topLeft: {
|
||||
x:
|
||||
keyObject.x +
|
||||
Math.floor(-coordinateSystem.shift.x / coordinateSystem.zoom / 256),
|
||||
y:
|
||||
keyObject.y +
|
||||
Math.floor(-coordinateSystem.shift.y / coordinateSystem.zoom / 256),
|
||||
},
|
||||
bottomRight: {
|
||||
x:
|
||||
keyObject.x +
|
||||
Math.ceil(
|
||||
(-coordinateSystem.shift.x + nearerHTMLParent.offsetWidth) /
|
||||
coordinateSystem.zoom /
|
||||
256
|
||||
) -
|
||||
1,
|
||||
y:
|
||||
keyObject.y +
|
||||
Math.ceil(
|
||||
(-coordinateSystem.shift.y + nearerHTMLParent.offsetHeight) /
|
||||
coordinateSystem.zoom /
|
||||
256
|
||||
) -
|
||||
1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [props.keyObject, props.coordinateSystem]);
|
||||
return viewPort;
|
||||
};
|
||||
export default useViewport;
|
||||
Reference in new issue
Block a user