Skip to content

Commit

Permalink
feature(Widget): add a minimap widget
Browse files Browse the repository at this point in the history
  • Loading branch information
mgermerie authored and gchoqueux committed Jan 31, 2022
1 parent 06eb805 commit 6d82c74
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 35 deletions.
62 changes: 48 additions & 14 deletions examples/css/widgets.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
/* ---------- GENERIC WIDGET SETTINGS : ----------------------------------------------------------------------------- */

/* Define widget position according to its position settings. */
.top-widget {
top: 10px;
}
.bottom-widget {
bottom: 10px;
}
.left-widget {
left: 10px;
}
.right-widget {
right: 10px;
}



/* ---------- NAVIGATION WIDGET SETTINGS : -------------------------------------------------------------------------- */

#widgets-navigation {
Expand All @@ -22,20 +40,6 @@
flex-direction: row-reverse;
}

/* Define navigation menu position according to its position settings. */
#widgets-navigation.top-widget {
top: 10px;
}
#widgets-navigation.bottom-widget {
bottom: 10px;
}
#widgets-navigation.left-widget {
left: 10px;
}
#widgets-navigation.right-widget {
right: 10px;
}

/* Define spacing between each navigation widgets according to position and direction settings. */
#widgets-navigation.column-widget.top-widget > *:not(:first-child),
#widgets-navigation.column-widget.bottom-widget > *:not(:last-child) {
Expand Down Expand Up @@ -212,3 +216,33 @@
background-size: auto 90%;
background-position: center;
}



/* ---------- MINIMAP WIDGET SETTINGS : ----------------------------------------------------------------------------- */

#widgets-minimap {
position: absolute;
z-index: 10;

border: 1px solid #222222;
border-radius: 7px;
overflow: hidden;

user-select: none;
}

#widgets-minimap #cursor-wrapper {
position: absolute;
z-index: 11;

display: flex;
align-items: center;
justify-content: center;

width: 100%;
height: 100%;

font-size: 40px;
color: #222222;
}
1 change: 1 addition & 0 deletions utils/gui/Main.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
// eslint-disable-next-line import/prefer-default-export
export { default as Navigation } from './Navigation';
export { default as Minimap } from './Minimap';
166 changes: 166 additions & 0 deletions utils/gui/Minimap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import Coordinates from 'Core/Geographic/Coordinates';
import { MAIN_LOOP_EVENTS } from 'Core/MainLoop';
import PlanarView from 'Core/Prefab/PlanarView';
import { CAMERA_TYPE } from 'Renderer/Camera';
import Widget from './Widget';


const DEFAULT_OPTIONS = {
minScale: 1 / 500000,
maxScale: 1 / 5E8,
zoomRatio: 1 / 30,
width: 150,
height: 150,
position: 'bottom-left',
};


/**
* A widget for minimap
*
* @property {HTMLElement} domElement An html div containing the minimap.
* @property {HTMLElement} parentElement The parent HTML container of `this.domElement`.
*/
class Minimap extends Widget {
/**
* @param {GlobeView} view The iTowns view the minimap should be
* linked to. Only {@link GlobeView} is
* supported at the moment.
* @param {ColorLayer} layer The {@link ColorLayer} that should be
* displayed on the minimap.
* @param {Object} [options] The minimap optional configuration.
* @param {HTMLElement} [options.parentElement=view.domElement] The parent HTML container of the div
* which contains minimap widgets.
* @param {number} [options.size] The size of the minimap. It is a number
* that describing both width and height
* in pixels of the minimap.
* @param {number} [options.width=150] The width in pixels of the minimap.
* @param {number} [options.height=150] The height in pixels of the minimap.
* @param {string} [options.position='bottom-left'] Defines which corner of the
* `parentElement` the minimap should be
* displayed to. Possible values are
* `top-left`, `top-right`, `bottom-left`
* and `bottom-right`. If the input value
* does not match one of these, it will
* be defaulted to `bottom-left`.
* @param {Object} [options.translate] An optional translation of the minimap.
* @param {number} [options.translate.x=0] The minimap translation along the page
* x-axis.
* @param {number} [options.translate.y=0] The minimap translation along the page
* y-axis.
* @param {HTMLElement|string} [options.cursor] An html element or an HTML string
* describing a cursor showing minimap
* view camera target position at the
* center of the minimap.
* @param {number} [options.minScale=1/2000] The minimal scale the minimap can reach.
* @param {number} [options.maxScale=1/1_250_000] The maximal scale the minimap can reach.
* @param {number} [options.zoomRatio=1/30] The ratio between minimap camera zoom
* and view camera zoom.
* @param {number} [options.pitch=0.28] The screen pixel pitch, used to compute
* view and minimap scale.
*/
constructor(view, layer, options = {}) {
// ---------- BUILD PROPERTIES ACCORDING TO DEFAULT OPTIONS AND OPTIONS PASSED IN PARAMETERS : ----------

if (!view.isGlobeView) {
throw new Error(
'\'Minimap\' plugin only supports \'GlobeView\'. Therefore, the \'view\' parameter must be a ' +
'\'GlobeView\'.',
);
}
if (!layer.isColorLayer) {
throw new Error('\'layer\' parameter form \'Minimap\' constructor should be a \'ColorLayer\'.');
}

super(view, options, DEFAULT_OPTIONS);

this.minScale = options.minScale || DEFAULT_OPTIONS.minScale;
this.maxScale = options.maxScale || DEFAULT_OPTIONS.maxScale;

// TODO : it could be interesting to be able to specify a method as zoomRatio parameter. This method could
// return a zoom ratio from the scale of the minimap.
this.zoomRatio = options.zoomRatio || DEFAULT_OPTIONS.zoomRatio;


// ---------- this.domElement SETTINGS SPECIFIC TO MINIMAP : ----------

this.domElement.id = 'widgets-minimap';

// Display a cursor at the center of the minimap, if requested.
if (options.cursor) {
// Wrap cursor domElement inside a div to center it in minimap.
const cursorWrapper = document.createElement('div');
cursorWrapper.id = 'cursor-wrapper';
this.domElement.appendChild(cursorWrapper);

// Add specified cursor to its wrapper.
if (typeof options.cursor === 'string') {
cursorWrapper.innerHTML = options.cursor;
} else if (options.cursor instanceof HTMLElement) {
cursorWrapper.appendChild(options.cursor);
}
}



// ---------- CREATE A MINIMAP View AND DISPLAY DATA PASSED IN Layer PARAMETER : ----------

this.view = new PlanarView(this.domElement, layer.source.extent, {
camera: { type: CAMERA_TYPE.ORTHOGRAPHIC },
placement: layer.source.extent, // TODO : the default placement should be the view extent for ortho camera
noControls: true,
maxSubdivisionLevel: view.tileLayer.maxSubdivisionLevel,
});
this.view.addLayer(layer); // TODO : should this promise be returned by constructor so that user can use it ?

// Give the focus back to the main view. Indeed, `View` constructor takes the focus, and we don't want the focus
// on the latest created `View`, which is the minimap view.
view.domElement.focus();
// Prevent the minimap domElement to get focus when clicked, and prevent click event to be propagated to the
// main view controls.
this.domElement.addEventListener('pointerdown', (event) => {
event.stopPropagation();
event.preventDefault();
});

// Store minimap view camera3D in constant for code convenience.
const camera3D = this.view.camera.camera3D;



// ---------- UPDATE MINIMAP VIEW WHEN UPDATING THE MAIN VIEW : ----------

// The minimal and maximal value the minimap camera3D zoom can reach in order to stay in the scale limits.
const initialScale = this.view.getScale(options.pitch);
const minZoom = camera3D.zoom * this.maxScale / initialScale;
const maxZoom = camera3D.zoom * this.minScale / initialScale;

// Coordinates used to transform position vectors from the main view CRS to the minimap view CRS.
const mainViewCoordinates = new Coordinates(view.referenceCrs);
const viewCoordinates = new Coordinates(this.view.referenceCrs);

const targetPosition = view.controls.getCameraTargetPosition();

view.addFrameRequester(MAIN_LOOP_EVENTS.AFTER_RENDER, () => {
// Update minimap camera zoom
const distance = view.camera.camera3D.position.distanceTo(targetPosition);
const scale = view.getScaleFromDistance(options.pitch, distance);
camera3D.zoom = this.zoomRatio * maxZoom * scale / this.minScale;
camera3D.zoom = Math.min(Math.max(camera3D.zoom, minZoom), maxZoom);
camera3D.updateProjectionMatrix();

// Update minimap camera position.
mainViewCoordinates.setFromVector3(view.controls.getCameraTargetPosition());
mainViewCoordinates.as(this.view.referenceCrs, viewCoordinates);

camera3D.position.x = viewCoordinates.x;
camera3D.position.y = viewCoordinates.y;
camera3D.updateMatrixWorld(true);

this.view.notifyChange(camera3D);
});
}
}


export default Minimap;
33 changes: 12 additions & 21 deletions utils/gui/Navigation.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { VIEW_EVENTS } from 'Core/View';
import Widget from './Widget';


const DEFAULT_OPTIONS = {
Expand All @@ -19,7 +20,7 @@ const DEFAULT_OPTIONS = {
* @property {HTMLElement} domElement An html div containing all navigation widgets.
* @property {HTMLElement} parentElement The parent HTML container of `this.domElement`.
*/
class Navigation {
class Navigation extends Widget {
/**
* @param {View} view The iTowns view the navigation should be linked
* to.
Expand Down Expand Up @@ -53,7 +54,16 @@ class Navigation {
constructor(view, options = {}) {
// ---------- BUILD PROPERTIES ACCORDING TO DEFAULT OPTIONS AND OPTIONS PASSED IN PARAMETERS : ----------

this.parentElement = options.parentElement || DEFAULT_OPTIONS.parentElement;
// `top`, `bottom`, `left` and `right` values for `position` option are not relevant for navigation widget.
if (['top', 'bottom', 'left', 'right'].includes(options.position)) {
console.warn(
'\'position\' optional parameter for \'Navigation\' is not a valid option. ' +
`It will be set to '${DEFAULT_OPTIONS.position}'.`,
);
options.position = DEFAULT_OPTIONS.position;
}

super(view, options, DEFAULT_OPTIONS);

this.direction = options.direction || DEFAULT_OPTIONS.direction;
if (!['column', 'row'].includes(this.direction)) {
Expand All @@ -64,15 +74,6 @@ class Navigation {
this.direction = DEFAULT_OPTIONS.direction;
}

this.position = options.position || DEFAULT_OPTIONS.position;
if (!['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(this.position)) {
console.warn(
'\'position\' optional parameter for \'Navigation\' constructor is not a valid option. '
+ `It will be set to '${DEFAULT_OPTIONS.position}'.`,
);
this.position = DEFAULT_OPTIONS.position;
}

this.animationDuration = options.animationDuration === undefined ?
DEFAULT_OPTIONS.animationDuration : options.animationDuration;

Expand All @@ -81,21 +82,11 @@ class Navigation {
// ---------- CREATE A DomElement WITH id AND classes RELEVANT TO THE WIDGET PROPERTIES : ----------

// Create a div containing all widgets and add it to its specified parent.
this.domElement = document.createElement('div');
this.domElement.id = 'widgets-navigation';
this.parentElement.appendChild(this.domElement);

// Position widget div according to options.
const positionArray = this.position.split('-');
this.domElement.classList.add(`${positionArray[0]}-widget`);
this.domElement.classList.add(`${positionArray[1]}-widget`);
this.domElement.classList.add(`${this.direction}-widget`);

// Translate widget div according to optional translate parameter.
if (options.translate) {
this.domElement.style.transform = `translate(${options.translate.x || 0}px, ${options.translate.y || 0}px)`;
}



// ---------- CREATE THE DEFAULT WIDGETS IF NOT HIDDEN (COMPASS, 3D AND ZOOM BUTTONS) : ----------
Expand Down
59 changes: 59 additions & 0 deletions utils/gui/Widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
class Widget {
constructor(view, options = {}, defaultOptions) {
this.parentElement = options.parentElement || view.domElement;

this.position = options.position || defaultOptions.position;
if (
!['top-left', 'top-right', 'bottom-left', 'bottom-right', 'top', 'bottom', 'left', 'right']
.includes(this.position)
) {
console.warn(
'\'position\' optional parameter for \'Widget\' constructor is not a valid option. '
+ `It will be set to '${defaultOptions.position}'.`,
);
this.position = defaultOptions.position;
}



// ---------- CREATE A DomElement WITH id, classes AND style RELEVANT TO THE WIDGET PROPERTIES : ----------

// Create a div containing minimap widget and add it to its specified parent.
this.domElement = document.createElement('div');
this.parentElement.appendChild(this.domElement);

// Size widget according to options.
this.domElement.style.width = `${options.width || options.size || defaultOptions.width}px`;
this.domElement.style.height = `${options.height || options.size || defaultOptions.height}px`;

// Position widget according to options.
const positionArray = this.position.split('-');
this.domElement.classList.add(`${positionArray[0]}-widget`);
if (positionArray[1]) {
this.domElement.classList.add(`${positionArray[1]}-widget`);
} else {
// If only one position parameter was given, center the domElement on the other axis.
// TODO : at this stage, offsetWidth and offsetHeight do no include borders. This should be worked around.
switch (positionArray[0]) {
case 'top':
case 'bottom':
this.domElement.style.left = `calc(50% - ${this.domElement.offsetWidth / 2}px)`;
break;
case 'left':
case 'right':
this.domElement.style.top = `calc(50% - ${this.domElement.offsetHeight / 2}px)`;
break;
default:
break;
}
}

// Translate widget div according to optional translate parameter.
if (options.translate) {
this.domElement.style.transform = `translate(${options.translate.x || 0}px, ${options.translate.y || 0}px)`;
}
}
}


export default Widget;

0 comments on commit 6d82c74

Please sign in to comment.