21646 lines
750 KiB
JavaScript
21646 lines
750 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
/**
|
|
* Class that represents a PixiJS Theme.
|
|
*
|
|
* @example
|
|
* // Create the theme
|
|
* const yellow = new Theme({
|
|
* fill: 0xfecd2d,
|
|
* fillActive: 0xfe9727,
|
|
* strokeActive: 0xfecd2d,
|
|
* strokeActiveWidth: 4,
|
|
* textStyle: {
|
|
* fill: 0x5ec7f8
|
|
* },
|
|
* textStyleActive: {
|
|
* fill: 0x5954d3
|
|
* },
|
|
* textStyleLarge: {
|
|
* fontSize: 36
|
|
* }
|
|
* })
|
|
*
|
|
* // Create the app and apply the new theme to it
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 450,
|
|
* height: 150,
|
|
* theme: yellow
|
|
* }).setup().run()
|
|
*
|
|
* @class
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
|
|
*/
|
|
class Theme {
|
|
/**
|
|
* Creates an instance of a Theme.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the theme.
|
|
* @param {number} [opts.margin=10] - The outer spacing (distance to other objects) from the border.
|
|
* @param {number} [opts.padding=10] - The inner spacing (distance from icon and/or label) to the border.
|
|
* @param {number} [opts.radius=4] - The radius used when drawing a rounded rectangle.
|
|
* @param {number} [opts.fast=0.25] - The duration of time when it has to be fast.
|
|
* @param {number} [opts.normal=0.5] - The duration of time when it has to be normal.
|
|
* @param {number} [opts.slow=1] - The duration of time when it has to be slow.
|
|
* @param {number} [opts.primaryColor=0x5ec7f8] - The primary color of the theme.
|
|
* @param {number} [opts.color1=0x282828] - The first color of the theme. For example used for the background.
|
|
* @param {number} [opts.color2=0xf6f6f6] - The second color of the theme. For example used for the border.
|
|
* @param {number} [opts.fill=color1] - The color of the background as a hex value.
|
|
* @param {number} [opts.fillAlpha=1] - The alpha value of the background.
|
|
* @param {number} [opts.fillActive=color1] - The color of the background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=1] - The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=color2] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=0.6] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=1] - The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=color2] - The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=0.6] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=1] - The alpha value of the border when activated.
|
|
* @param {number} [opts.iconColor=color2] - The color of the icon (set by the tint property) as a hex value.
|
|
* @param {number} [opts.iconColorActive=colorPrimary] - The color of the icon when activated.
|
|
* @param {number} [opts.background=color1] - The color of a background for a component (e.g. at the Modal class).
|
|
* @param {object} [opts.textStyle={}] - A textstyle object for the styling of text. See PIXI.TextStyle
|
|
* for possible options. Default object:
|
|
* @param {string} [opts.textStyle.fontFamily="Avenir Next", "Open Sans", "Segoe UI", ...] - The font family.
|
|
* @param {string} [opts.textStyle.fontWeight=400] - The font weight.
|
|
* @param {number} [opts.textStyle.fontSize=16] - The font size.
|
|
* @param {number} [opts.textStyle.fill=color2] - The fill color.
|
|
* @param {number} [opts.textStyle.stroke=color1] - The stroke color.
|
|
* @param {number} [opts.textStyle.strokeThickness=0] - The thickness of the stroke.
|
|
* @param {number} [opts.textStyle.miterLimit=1] - The meter limit.
|
|
* @param {string} [opts.textStyle.lineJoin=round] - The line join.
|
|
* @param {object} [opts.textStyleActive=textStyle + {fill: primaryColor}] - A textstyle object which is used
|
|
* for actived text.
|
|
* @param {object} [opts.textStyleSmall=textStyle + {fontSize: -= 3}] - A textstyle object which is used for
|
|
* small text.
|
|
* @param {object} [opts.textStyleSmallActive=textStyleSmall + {fill: primaryColor}] - A textstyle object which
|
|
* is used for small actived text.
|
|
* @param {object} [opts.textStyleLarge=textStyle + {fontSize: += 3}] - A textstyle object which is used for
|
|
* large text.
|
|
* @param {object} [opts.textStyleLargeActive=textStyleLarge + {fill: primaryColor}] - A textstyle object which
|
|
* is used for large actived text.
|
|
* @param {number} [opts.textAlpha=Theme.textAlpha] - The alpha value of text.
|
|
* @param {number} [opts.textActiveAlpha=Theme.textActiveAlpha] - The alpha value of text when activated.
|
|
*/
|
|
constructor(opts = {}) {
|
|
const colorPrimary = opts.primaryColor != null ? opts.primaryColor : 0x5ec7f8; // blue
|
|
const color1 = opts.color1 != null ? opts.color1 : 0x282828; // black
|
|
const color2 = opts.color2 != null ? opts.color2 : 0xf6f6f6; // white
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
margin: 12,
|
|
padding: 12,
|
|
radius: 4,
|
|
fast: 0.25,
|
|
normal: 0.5,
|
|
slow: 1,
|
|
primaryColor: colorPrimary,
|
|
color1: color1,
|
|
color2: color2,
|
|
fill: color1,
|
|
fillAlpha: 1,
|
|
fillActive: color1,
|
|
fillActiveAlpha: 1,
|
|
stroke: color2,
|
|
strokeWidth: 0.6,
|
|
strokeAlpha: 1,
|
|
strokeActive: color2,
|
|
strokeActiveWidth: 0.6,
|
|
strokeActiveAlpha: 1,
|
|
textAlpha: 1,
|
|
textActiveAlpha: 1,
|
|
iconColor: color2,
|
|
iconColorActive: colorPrimary,
|
|
background: color1
|
|
},
|
|
opts
|
|
);
|
|
|
|
// Set textStyle and variants
|
|
this.opts.textStyle = Object.assign(
|
|
{},
|
|
{
|
|
fontFamily:
|
|
'"Avenir Next", "Open Sans", "Segoe UI", "Roboto", "Helvetica Neue", -apple-system, system-ui, BlinkMacSystemFont, Arial, sans-serif !default',
|
|
fontWeight: '500',
|
|
fontSize: 18,
|
|
fill: color2,
|
|
stroke: color1,
|
|
strokeThickness: 0,
|
|
miterLimit: 1,
|
|
lineJoin: 'round'
|
|
},
|
|
this.opts.textStyle
|
|
);
|
|
this.opts.textStyleSmall = Object.assign(
|
|
{},
|
|
this.opts.textStyle,
|
|
{ fontSize: this.opts.textStyle.fontSize - 3 },
|
|
this.opts.textStyleSmall
|
|
);
|
|
this.opts.textStyleLarge = Object.assign(
|
|
{},
|
|
this.opts.textStyle,
|
|
{ fontSize: this.opts.textStyle.fontSize + 3 },
|
|
this.opts.textStyleLarge
|
|
);
|
|
this.opts.textStyleActive = Object.assign(
|
|
{},
|
|
this.opts.textStyle,
|
|
{ fill: this.opts.primaryColor },
|
|
this.opts.textStyleActive
|
|
);
|
|
this.opts.textStyleSmallActive = Object.assign(
|
|
{},
|
|
this.opts.textStyleSmall,
|
|
{ fill: this.opts.primaryColor },
|
|
this.opts.textStyleSmallActive
|
|
);
|
|
this.opts.textStyleLargeActive = Object.assign(
|
|
{},
|
|
this.opts.textStyleLarge,
|
|
{ fill: this.opts.primaryColor },
|
|
this.opts.textStyleLargeActive
|
|
);
|
|
|
|
Object.assign(this, this.opts);
|
|
}
|
|
|
|
/**
|
|
* Factory function
|
|
*
|
|
* @static
|
|
* @param {string} theme=dark - The name of the theme to load.
|
|
* @return {Theme} Returns a newly created Theme object.
|
|
*/
|
|
static fromString(theme) {
|
|
if (theme && typeof theme === 'object') {
|
|
return theme
|
|
}
|
|
|
|
switch (theme) {
|
|
case 'light':
|
|
return new ThemeLight()
|
|
case 'red':
|
|
return new ThemeRed()
|
|
default:
|
|
return new ThemeDark()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS ThemeDark.
|
|
*
|
|
* @example
|
|
* // Create the app with a new dark theme
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 450,
|
|
* height: 150,
|
|
* theme: new ThemeDark()
|
|
* }).setup().run()
|
|
*
|
|
* @class
|
|
* @extends Theme
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
|
|
*/
|
|
class ThemeDark extends Theme {}
|
|
|
|
/**
|
|
* Class that represents a PixiJS ThemeLight.
|
|
* The color1 is set to 0xf6f6f6, color2 to 0x282828.
|
|
*
|
|
* @example
|
|
* // Create the app with a new light theme
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 450,
|
|
* height: 150,
|
|
* theme: new ThemeLight()
|
|
* }).setup().run()
|
|
*
|
|
* @class
|
|
* @extends Theme
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
|
|
*/
|
|
class ThemeLight extends Theme {
|
|
/**
|
|
* Creates an instance of a ThemeLight.
|
|
*
|
|
* @constructor
|
|
*/
|
|
constructor() {
|
|
super({ color1: 0xf6f6f6, color2: 0x282828 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS ThemeRed.
|
|
* The primaryColor is set to 0xd92f31.
|
|
*
|
|
* @example
|
|
* // Create the app with a new red theme
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 450,
|
|
* height: 150,
|
|
* theme: new ThemeRed()
|
|
* }).setup().run()
|
|
*
|
|
* @class
|
|
* @extends Theme
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
|
|
*/
|
|
class ThemeRed extends Theme {
|
|
/**
|
|
* Creates an instance of a ThemeRed.
|
|
*
|
|
* @constructor
|
|
*/
|
|
constructor() {
|
|
super({ primaryColor: 0xd92f31 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Progress.
|
|
*
|
|
* @example
|
|
* // Create the progress
|
|
* const progress = new Progress({
|
|
* app: app
|
|
* })
|
|
*
|
|
* // Add the progress to a DisplayObject
|
|
* app.scene.addChild(progress)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/progress.html|DocTest}
|
|
*/
|
|
class Progress extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Progress.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the progress.
|
|
* @param {number} [opts.id=auto generated] - The id of the progress.
|
|
* @param {PIXIApp} [opts.app=window.app] - The app where the progress belongs to.
|
|
* @param {number} [opts.width] - The width of the progress bar. When not set, the width is the size of the app
|
|
* minus 2 * opts.margin.
|
|
* @param {number} [opts.height=2] - The height of the progress bar.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this progress. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.margin=100] - The outer spacing to the edges of the app.
|
|
* @param {number} [opts.padding=0] - The inner spacing (distance from icon and/or label) to the border.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the progress background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.fillActive=Theme.primaryColor] - The color of the progress background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=0] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=0] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
|
|
* @param {boolean} [opts.background=false] - The alpha value of the border when activated.
|
|
* @param {number} [opts.backgroundFill=Theme.background] - A textstyle object for the styling of the label. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {number} [opts.backgroundFillAlpha=1] - A textstyle object for the styling of the label when the
|
|
* progress is activated. See PIXI.TextStyle for possible options.
|
|
* @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the progress (which is a rounded rectangle).
|
|
* @param {boolean} [opts.destroyOnComplete=true] - Should the progress bar destroy itself after reaching 100 %?
|
|
* @param {boolean} [opts.visible=true] - Is the progress initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
app: window.app,
|
|
width: null,
|
|
height: 2,
|
|
margin: 100,
|
|
padding: 0,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
fillActive: theme.primaryColor,
|
|
fillActiveAlpha: theme.fillActiveAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: 0,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
strokeActive: theme.strokeActive,
|
|
strokeActiveWidth: 0,
|
|
strokeActiveAlpha: theme.strokeActiveAlpha,
|
|
background: false,
|
|
backgroundFill: theme.background,
|
|
backgroundFillAlpha: 1,
|
|
radius: theme.radius,
|
|
destroyOnComplete: true,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
this.background = null;
|
|
this.bar = null;
|
|
this.barActive = null;
|
|
|
|
this.alpha = 0;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
this._progress = 0;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
setup() {
|
|
// interaction
|
|
//-----------------
|
|
this.on('added', e => {
|
|
this.show();
|
|
});
|
|
|
|
// background
|
|
//-----------------
|
|
if (this.opts.background) {
|
|
const background = new PIXI.Graphics();
|
|
this.background = background;
|
|
this.addChild(background);
|
|
}
|
|
|
|
// bar
|
|
//-----------------
|
|
const bar = new PIXI.Graphics();
|
|
this.bar = bar;
|
|
this.addChild(bar);
|
|
|
|
const barActive = new PIXI.Graphics();
|
|
this.barActive = barActive;
|
|
this.bar.addChild(barActive);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the progress. Can be used after resizing.
|
|
*
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
layout() {
|
|
const width = this.opts.app.size.width;
|
|
const height = this.opts.app.size.height;
|
|
|
|
// background
|
|
//-----------------
|
|
if (this.opts.background) {
|
|
this.background.clear();
|
|
this.background.beginFill(this.opts.backgroundFill, this.opts.backgroundFillAlpha);
|
|
this.background.drawRect(0, 0, width, height);
|
|
this.background.endFill();
|
|
}
|
|
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the canvas.
|
|
*
|
|
* @private
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
draw() {
|
|
this.bar.clear();
|
|
this.barActive.clear();
|
|
|
|
this.drawBar();
|
|
this.drawBarActive();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the bar.
|
|
*
|
|
* @private
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
drawBar() {
|
|
const width = this.opts.app.size.width;
|
|
const height = this.opts.app.size.height;
|
|
|
|
this.radius = this.opts.radius;
|
|
if (this.radius * 2 > this.opts.height) {
|
|
this.radius = this.opts.height / 2;
|
|
}
|
|
|
|
const wantedWidth = this.opts.width || width - 2 * this.opts.margin;
|
|
const wantedHeight = this.opts.height;
|
|
|
|
this.bar.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.bar.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
if (this.radius > 1) {
|
|
this.bar.drawRoundedRect(0, 0, wantedWidth, wantedHeight, this.radius);
|
|
} else {
|
|
this.bar.drawRect(0, 0, wantedWidth, wantedHeight);
|
|
}
|
|
this.bar.endFill();
|
|
|
|
this.bar.x = width / 2 - this.bar.width / 2;
|
|
this.bar.y = height / 2 - this.bar.height / 2;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the active bar.
|
|
*
|
|
* @private
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
drawBarActive() {
|
|
const wantedWidth = this.bar.width - 2 * this.opts.padding;
|
|
const wantedHeight = this.bar.height - 2 * this.opts.padding;
|
|
|
|
const barActiveWidth = (wantedWidth * this._progress) / 100;
|
|
|
|
this.barActive.lineStyle(this.opts.strokeActiveWidth, this.opts.strokeActive, this.opts.strokeActiveAlpha);
|
|
this.barActive.beginFill(this.opts.fillActive, this.opts.fillActiveAlpha);
|
|
if (barActiveWidth > 0) {
|
|
if (this.radius > 1) {
|
|
this.barActive.drawRoundedRect(0, 0, barActiveWidth, wantedHeight, this.radius);
|
|
} else {
|
|
this.barActive.drawRect(0, 0, barActiveWidth, wantedHeight);
|
|
}
|
|
}
|
|
this.barActive.endFill();
|
|
|
|
this.barActive.x = this.opts.padding;
|
|
this.barActive.y = this.opts.padding;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Shows the progress (sets his alpha values to 1).
|
|
*
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
show() {
|
|
TweenLite.to(this, this.theme.fast, { alpha: 1 });
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the progress (sets his alpha values to 1).
|
|
*
|
|
* @return {Progress} A reference to the progress for chaining.
|
|
*/
|
|
hide() {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 0,
|
|
onComplete: () => (this.visible = false)
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the progress. Has to be a number between 0 and 100.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get progress() {
|
|
return this._progress
|
|
}
|
|
set progress(value) {
|
|
value = Math.round(value);
|
|
|
|
if (value < 0) {
|
|
value = 0;
|
|
}
|
|
|
|
if (value > 100) {
|
|
value = 100;
|
|
}
|
|
|
|
TweenLite.to(this, this.theme.normal, {
|
|
_progress: value,
|
|
onUpdate: () => this.draw(),
|
|
onComplete: () => {
|
|
if (value === 100 && this.opts.destroyOnComplete) {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 0,
|
|
onComplete: () => this.destroy({ children: true })
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS AbstractPopup.
|
|
* The class is used for various other Popup-like classes
|
|
* like Popup, Message, Tooltip...
|
|
*
|
|
* @class
|
|
* @abstract
|
|
* @extends PIXI.Graphics
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
|
|
*/
|
|
class AbstractPopup extends PIXI.Graphics {
|
|
/**
|
|
* Creates an instance of an AbstractPopup (only for internal use).
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the popup.
|
|
* @param {number} [opts.id=auto generated] - The id of the popup.
|
|
* @param {number} [opts.x=0] - The x position of the popup. Can be also set after creation with popup.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the popup. Can be also set after creation with popup.y = 0.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this popup. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {string|number|PIXI.Text} [opts.header] - The heading inside the popup as a string, a number (will be
|
|
* converted to a text) or as a PIXI.Text object.
|
|
* @param {string|number|PIXI.DisplayObject} [opts.content] - A text, a number (will be converted to a text) or
|
|
* an PIXI.DisplayObject as the content of the popup.
|
|
* @param {number} [opts.minWidth=320] - The minimum width of the popup.
|
|
* @param {number} [opts.minHeight=130] - The minimum height of the popup.
|
|
* @param {number} [opts.padding=Theme.padding] - The inner spacing (distance from header and content) the the border.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the button background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {object} [opts.headerStyle=Theme.textStyleLarge] - A textstyle object for the styling of the header. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {object} [opts.textStyle=Theme.textStyleSmall] - A textstyle object for the styling of the text. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the popup (which is a rounded rectangle).
|
|
* @param {hiddenCallback} [opts.onHidden] - Executed when the popup gets hidden.
|
|
* @param {boolean} [opts.visible=true] - Is the popup initially visible (property visible)?
|
|
* @param {string} [opts.orientation] - When set to portrait, the popup cannot be displayed in landscape mode. When set
|
|
* to landscape, the popup cannot be displayed in portrait mode.
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
x: 0,
|
|
y: 0,
|
|
header: null, // null or null
|
|
content: null, // null or String or PIXI.DisplayObject
|
|
minWidth: 320,
|
|
minHeight: 130,
|
|
maxWidth: null,
|
|
padding: theme.padding,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
headerStyle: theme.textStyleLarge,
|
|
textStyle: theme.textStyleSmall,
|
|
radius: theme.radius,
|
|
onHidden: null,
|
|
visible: true,
|
|
orientation: null
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
this.headerStyle = new PIXI.TextStyle(this.opts.headerStyle);
|
|
this.textStyle = new PIXI.TextStyle(this.opts.textStyle);
|
|
|
|
if (this.opts.maxWidth) {
|
|
this.headerStyle.wordWrap = true;
|
|
this.headerStyle.wordWrapWidth = this.opts.maxWidth - 2 * this.opts.padding;
|
|
|
|
this.textStyle.wordWrap = true;
|
|
this.textStyle.wordWrapWidth = this.opts.maxWidth - 2 * this.opts.padding;
|
|
}
|
|
|
|
this.alpha = 0;
|
|
this.visible = this.opts.visible;
|
|
|
|
this._header = null;
|
|
this._content = null;
|
|
|
|
// position
|
|
this.x = this.opts.x;
|
|
this.y = this.opts.y;
|
|
|
|
// padding
|
|
this.innerPadding = this.opts.padding * 1.5;
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.interactive = true;
|
|
this.on('added', e => {
|
|
this.show();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates the framework and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
setup() {
|
|
// position
|
|
//-----------------
|
|
this.sy = this.opts.padding;
|
|
|
|
// header
|
|
//-----------------
|
|
if (this.opts.header != null) {
|
|
let header = null;
|
|
|
|
if (this.opts.header instanceof PIXI.Text) {
|
|
header = this.opts.header;
|
|
} else if (typeof this.opts.header === 'number') {
|
|
header = new PIXI.Text(this.opts.header.toString(), this.headerStyle);
|
|
} else {
|
|
header = new PIXI.Text(this.opts.header, this.headerStyle);
|
|
}
|
|
|
|
header.x = this.opts.padding;
|
|
header.y = this.sy;
|
|
|
|
this.addChild(header);
|
|
|
|
this.sy += header.height;
|
|
|
|
this._header = header;
|
|
}
|
|
|
|
if (this.opts.header && this.opts.content) {
|
|
this.sy += this.innerPadding;
|
|
}
|
|
|
|
// content
|
|
//-----------------
|
|
if (this.opts.content != null) {
|
|
let content = null;
|
|
|
|
if (typeof this.opts.content === 'string') {
|
|
content = new PIXI.Text(this.opts.content, this.textStyle);
|
|
} else if (typeof this.opts.content === 'number') {
|
|
content = new PIXI.Text(this.opts.content.toString(), this.textStyle);
|
|
} else {
|
|
content = this.opts.content;
|
|
}
|
|
|
|
content.x = this.opts.padding;
|
|
content.y = this.sy;
|
|
|
|
this.sy += content.height;
|
|
|
|
this.addChild(content);
|
|
|
|
this._content = content;
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the popup. Can be used after resizing.
|
|
*
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
layout() {
|
|
// wanted width & wanted height
|
|
//-----------------
|
|
const padding = this.opts.padding;
|
|
const size = this.getInnerSize();
|
|
const width = size.width + 2 * padding;
|
|
const height = size.height + 2 * padding;
|
|
|
|
this.wantedWidth = Math.max(width, this.opts.minWidth);
|
|
this.wantedHeight = Math.max(height, this.opts.minHeight);
|
|
|
|
if (this.opts.maxWidth) {
|
|
this.wantedWidth = Math.min(this.wantedWidth, this.opts.maxWidth);
|
|
}
|
|
|
|
if (this.opts.radius * 2 > this.wantedWidth) {
|
|
this.wantedWidth = this.opts.radius * 2;
|
|
}
|
|
|
|
if (this.opts.radius * 2 > this.wantedHeight) {
|
|
this.wantedHeight = this.opts.radius * 2;
|
|
}
|
|
|
|
switch (this.opts.orientation) {
|
|
case 'portrait':
|
|
if (this.wantedWidth > this.wantedHeight) {
|
|
this.wantedHeight = this.wantedWidth;
|
|
}
|
|
break
|
|
case 'landscape':
|
|
if (this.wantedHeight > this.wantedWidth) {
|
|
this.wantedWidth = this.wantedHeight;
|
|
}
|
|
break
|
|
}
|
|
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the canvas.
|
|
*
|
|
* @private
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
draw() {
|
|
const square = Math.round(this.wantedWidth) === Math.round(this.wantedHeight);
|
|
const diameter = Math.round(this.opts.radius * 2);
|
|
|
|
this.clear();
|
|
this.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
if (square && diameter === this.wantedWidth) {
|
|
this.drawCircle(this.wantedWidth / 2, this.wantedHeight / 2, this.opts.radius);
|
|
} else {
|
|
this.drawRoundedRect(0, 0, this.wantedWidth, this.wantedHeight, this.opts.radius);
|
|
}
|
|
this.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates the size of the children of the AbstractPopup.
|
|
* Cannot use getBounds() because it is not updated when children
|
|
* are removed.
|
|
*
|
|
* @private
|
|
* @returns {object} An JavaScript object width the keys width and height.
|
|
*/
|
|
getInnerSize() {
|
|
let width = 0;
|
|
let height = 0;
|
|
|
|
if (this._header) {
|
|
width = this._header.width;
|
|
height = this._header.height;
|
|
}
|
|
|
|
if (this._header && this._content) {
|
|
height += this.innerPadding;
|
|
}
|
|
|
|
if (this._content) {
|
|
width = Math.max(width, this._content.width);
|
|
height += this._content.height;
|
|
}
|
|
|
|
return { width, height }
|
|
}
|
|
|
|
/**
|
|
* Shows the popup (sets his alpha values to 1).
|
|
*
|
|
* @param {callback} [cb] - Executed when show animation was completed.
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
show(cb) {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 1,
|
|
onComplete: () => {
|
|
if (cb) {
|
|
cb.call(this);
|
|
}
|
|
}
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the popup (sets his alpha values to 0).
|
|
*
|
|
* @param {callback} [cb] - Executed when hide animation was completed.
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
hide(cb) {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 0,
|
|
onComplete: () => {
|
|
this.visible = false;
|
|
if (cb) {
|
|
cb.call(this);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (this.opts.onHidden) {
|
|
this.opts.onHidden.call(this, this);
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Sets or gets the header. The getter always returns a PIXI.Text object. The setter can receive
|
|
* a string, a number or a PIXI.Text object.
|
|
*
|
|
* @member {string|number|PIXI.Text}
|
|
*/
|
|
get header() {
|
|
return this._header
|
|
}
|
|
set header(value) {
|
|
if (this._header) {
|
|
this._header.destroy();
|
|
}
|
|
this.opts.header = value;
|
|
this.setup().layout();
|
|
}
|
|
|
|
/**
|
|
* Sets or gets the content. The getter always returns an PIXI.DisplayObject. The setter can receive
|
|
* a string, a number or a PIXI.DisplayObject.
|
|
*
|
|
* @member {string|number|PIXI.DisplayObject}
|
|
*/
|
|
get content() {
|
|
return this._content
|
|
}
|
|
set content(value) {
|
|
if (this._content) {
|
|
this._content.destroy();
|
|
}
|
|
this.opts.content = value;
|
|
this.setup().layout();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Tooltip.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* // Add an DisplayObject to the app
|
|
* const circle = new PIXI.Graphics()
|
|
* circle.beginFill(0x5251a3)
|
|
* circle.drawCircle(50, 50, 40)
|
|
* app.scene.addChild(circle)
|
|
*
|
|
* const tooltip = new Tooltip({
|
|
* object: circle,
|
|
* container: app.scene,
|
|
* content: 'Das Gesetz ist der Freund des Schwachen.'
|
|
* })
|
|
*
|
|
* @class
|
|
* @extends AbstractPopup
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/tooltip.html|DocTest}
|
|
*/
|
|
class Tooltip extends AbstractPopup {
|
|
/**
|
|
* Creates an instance of a Tooltip.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the tooltip.
|
|
* @param {number} [opts.minWidth=0] - The minimum width of the tooltip.
|
|
* @param {number} [opts.minHeight=0] - The minimum height of the tooltip.
|
|
* @param {number} [opts.padding=Theme.padding / 2] - The inner spacing of the tooltip.
|
|
* @param {PIXI.DisplayObject} opts.object - The object, where the tooltip should be displayed.
|
|
* @param {PIXI.DisplayObject} [opts.container=object] - The container where the tooltip should be attached to.
|
|
* @param {number} [opts.offsetLeft=8] - The horizontal shift of the tooltip.
|
|
* @param {number} [opts.offsetTop=-8] - The vertical shift of the tooltip.
|
|
* @param {number} [opts.delay=0] - A delay, after which the tooltip should be opened.
|
|
*/
|
|
constructor(opts = {}) {
|
|
const theme = Theme.fromString(opts.theme);
|
|
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
padding: theme.padding / 2,
|
|
object: null,
|
|
container: null,
|
|
offsetLeft: 8,
|
|
offsetTop: -8,
|
|
delay: 0
|
|
},
|
|
opts
|
|
);
|
|
|
|
opts.container = opts.container || opts.object;
|
|
|
|
super(opts);
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Tooltip} A reference to the tooltip for chaining.
|
|
*/
|
|
setup() {
|
|
super.setup();
|
|
|
|
// bind events this
|
|
//-----------------
|
|
this.interactive = true;
|
|
|
|
let mouseoverTooltip = false;
|
|
|
|
this.on('mouseover', e => {
|
|
mouseoverTooltip = true;
|
|
});
|
|
|
|
this.on('mouseout', e => {
|
|
mouseoverTooltip = false;
|
|
if (!mouseoverObject) {
|
|
this.hide(() => {
|
|
this.opts.container.removeChild(this);
|
|
});
|
|
}
|
|
});
|
|
|
|
// bind events object
|
|
//-----------------
|
|
const object = this.opts.object;
|
|
object.interactive = true;
|
|
|
|
let mouseoverObject = false;
|
|
|
|
object.on('mouseover', e => {
|
|
this.timeout = window.setTimeout(() => {
|
|
mouseoverObject = true;
|
|
this.visible = true;
|
|
this.opts.container.addChild(this);
|
|
this.setPosition(e);
|
|
}, this.opts.delay * 1000);
|
|
});
|
|
|
|
object.on('mousemove', e => {
|
|
if (mouseoverObject) {
|
|
this.setPosition(e);
|
|
}
|
|
});
|
|
|
|
object.on('mouseout', e => {
|
|
mouseoverObject = false;
|
|
window.clearTimeout(this.timeout);
|
|
if (!mouseoverTooltip) {
|
|
this.hide(() => {
|
|
this.opts.container.removeChild(this);
|
|
});
|
|
}
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates and sets the position of the tooltip.
|
|
*
|
|
* @private
|
|
* @return {Tooltip} A reference to the tooltip for chaining.
|
|
*/
|
|
setPosition(e) {
|
|
const position = e.data.getLocalPosition(this.opts.container);
|
|
|
|
this.x = position.x + this.opts.offsetLeft;
|
|
this.y = position.y + this.opts.offsetTop - this.height;
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Badge.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* // Add an DisplayObject to the app
|
|
* const circle = new PIXI.Graphics()
|
|
* circle.beginFill(0x5251a3)
|
|
* circle.drawCircle(50, 50, 40)
|
|
* app.scene.addChild(circle)
|
|
*
|
|
* const badge1 = new Badge({
|
|
* object: circle,
|
|
* container: app.scene,
|
|
* content: 'Das Gesetz ist der Freund des Schwachen.'
|
|
* })
|
|
*
|
|
* @class
|
|
* @extends AbstractPopup
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/badge.html|DocTest}
|
|
*/
|
|
class Badge extends AbstractPopup {
|
|
/**
|
|
* Creates an instance of a Badge.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the badge.
|
|
* @param {number} [opts.minWidth=0] - The minimum width of the badge.
|
|
* @param {number} [opts.minHeight=0] - The minimum height of the badge.
|
|
* @param {number} [opts.padding=Theme.padding / 2] - The inner spacing of the badge.
|
|
* @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
|
|
* to display.
|
|
*/
|
|
constructor(opts = {}) {
|
|
const theme = Theme.fromString(opts.theme);
|
|
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
padding: theme.padding / 2,
|
|
tooltip: null
|
|
},
|
|
opts
|
|
);
|
|
|
|
super(opts);
|
|
|
|
this.tooltip = null;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @override
|
|
* @return {Badge} A reference to the badge for chaining.
|
|
*/
|
|
setup() {
|
|
super.setup();
|
|
|
|
// tooltip
|
|
//-----------------
|
|
if (this.opts.tooltip) {
|
|
if (typeof this.opts.tooltip === 'string') {
|
|
this.tooltip = new Tooltip({
|
|
object: this,
|
|
content: this.opts.tooltip
|
|
});
|
|
} else {
|
|
this.opts.tooltip = Object.assign({}, { object: this }, this.opts.tooltip);
|
|
this.tooltip = new Tooltip(this.opts.tooltip);
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the badge. Can be used after resizing.
|
|
*
|
|
* @override
|
|
* @return {Badge} A reference to the badge for chaining.
|
|
*/
|
|
layout() {
|
|
super.layout();
|
|
|
|
this.content.x = this.width / 2 - this.content.width / 2 - this.opts.strokeWidth / 2;
|
|
this.content.y = this.height / 2 - this.content.height / 2 - this.opts.strokeWidth / 2;
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
class Events$1 {
|
|
static stop(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
static extractPoint(event) {
|
|
switch (event.constructor.name) {
|
|
case 'TouchEvent':
|
|
for (let i = 0; i < event.targetTouches.length; i++) {
|
|
let t = event.targetTouches[i];
|
|
return { x: t.clientX, y: t.clientY }
|
|
}
|
|
break
|
|
default:
|
|
return { x: event.clientX, y: event.clientY }
|
|
}
|
|
}
|
|
|
|
static isCaptured(event) {
|
|
if (event.__capturedBy) return true
|
|
return false
|
|
}
|
|
|
|
static capturedBy(event, obj) {
|
|
event.__capturedBy = obj;
|
|
}
|
|
|
|
static isPointerDown(event) {
|
|
// According to
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
|
|
// pointer events use the buttons feature to represent pressed buttons
|
|
return event.buttons
|
|
}
|
|
|
|
static isMouseDown(event) {
|
|
// Attempts to clone the which attribute of events failed in WebKit. May
|
|
// be this is a bug or a security feature. Workaround: we introduce
|
|
// a mouseDownSubstitute attribute that can be assigned to cloned
|
|
// events after instantiation.
|
|
if (Reflect.has(event, 'mouseDownSubstitute')) return event.mouseDownSubstitute
|
|
return event.buttons || event.which
|
|
}
|
|
|
|
static isSimulatedEvent(event) {
|
|
return Reflect.has(event, 'mouseDownSubstitute')
|
|
}
|
|
|
|
static isMouseRightClick(event) {
|
|
return event.buttons || event.which
|
|
}
|
|
|
|
static extractTouches(targets) {
|
|
let touches = [];
|
|
for (let i = 0; i < targets.length; i++) {
|
|
let t = targets[i];
|
|
touches.push({
|
|
targetSelector: this.selector(t.target),
|
|
identifier: t.identifier,
|
|
screenX: t.screenX,
|
|
screenY: t.screenY,
|
|
clientX: t.clientX,
|
|
clientY: t.clientY,
|
|
pageX: t.pageX,
|
|
pageY: t.pageY
|
|
});
|
|
}
|
|
return touches
|
|
}
|
|
|
|
static createTouchList(targets) {
|
|
let touches = [];
|
|
for (let i = 0; i < targets.length; i++) {
|
|
let t = targets[i];
|
|
let touchTarget = document.elementFromPoint(t.pageX, t.pageY);
|
|
let touch = new Touch(undefined, touchTarget, t.identifier, t.pageX, t.pageY, t.screenX, t.screenY);
|
|
touches.push(touch);
|
|
}
|
|
return new TouchList(...touches)
|
|
}
|
|
|
|
static extractEvent(timestamp, event) {
|
|
let targetSelector = this.selector(event.target);
|
|
let infos = {
|
|
type: event.type,
|
|
time: timestamp,
|
|
constructor: event.constructor,
|
|
data: {
|
|
targetSelector: targetSelector,
|
|
view: event.view,
|
|
mouseDownSubstitute: event.buttons || event.which, // which cannot be cloned directly
|
|
bubbles: event.bubbles,
|
|
cancelable: event.cancelable,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
layerX: event.layerX,
|
|
layerY: event.layerY,
|
|
pageX: event.pageX,
|
|
pageY: event.pageY,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: event.altKey,
|
|
shiftKey: event.shiftKey,
|
|
metaKey: event.metaKey
|
|
}
|
|
};
|
|
if (event.type.startsWith('touch')) {
|
|
// On Safari-WebKit the TouchEvent has layerX, layerY coordinates
|
|
let data = infos.data;
|
|
data.targetTouches = this.extractTouches(event.targetTouches);
|
|
data.changedTouches = this.extractTouches(event.changedTouches);
|
|
data.touches = this.extractTouches(event.touches);
|
|
}
|
|
if (event.type.startsWith('pointer')) {
|
|
let data = infos.data;
|
|
data.pointerId = event.pointerId;
|
|
data.pointerType = event.pointerType;
|
|
}
|
|
if (Events$1.debug) {
|
|
Events$1.extracted.push(this.toLine(event));
|
|
}
|
|
return infos
|
|
}
|
|
|
|
static cloneEvent(type, constructor, data) {
|
|
if (type.startsWith('touch')) {
|
|
// We need to find target from layerX, layerY
|
|
//var target = document.querySelector(data.targetSelector)
|
|
// elementFromPoint(data.layerX, data.layerY)
|
|
//data.target = target
|
|
data.targetTouches = this.createTouchList(data.targetTouches);
|
|
data.changedTouches = this.createTouchList(data.changedTouches);
|
|
data.touches = this.createTouchList(data.touches);
|
|
}
|
|
// We need to find target from pageX, pageY which are only
|
|
// available after construction. They seem to getter items.
|
|
|
|
let clone = Reflect.construct(constructor, [type, data]);
|
|
clone.mouseDownSubstitute = data.mouseDownSubstitute;
|
|
return clone
|
|
}
|
|
|
|
static simulateEvent(type, constructor, data) {
|
|
data.target = document.querySelector(data.targetSelector);
|
|
let clone = this.cloneEvent(type, constructor, data);
|
|
if (data.target != null) {
|
|
data.target.dispatchEvent(clone);
|
|
}
|
|
if (Events$1.debug) {
|
|
Events$1.simulated.push(this.toLine(clone));
|
|
}
|
|
}
|
|
|
|
static toLine(event) {
|
|
return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}`
|
|
let result = event.type;
|
|
let selector = this.selector(event.target);
|
|
result += ' selector: ' + selector;
|
|
if (event.target != document.querySelector(selector)) console.log('Cannot resolve', selector);
|
|
let keys = ['layerX', 'layerY', 'pageX', 'pageY', 'clientX', 'clientY'];
|
|
for (let key of keys) {
|
|
try {
|
|
result += ' ' + key + ':' + event[key];
|
|
} catch (e) {
|
|
console.log('Invalid key: ' + key);
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
static compareExtractedWithSimulated() {
|
|
if (this.extracted.length != this.simulated.length) {
|
|
alert(
|
|
'Unequal length of extracted [' +
|
|
this.extracted.length +
|
|
'] and simulated events [' +
|
|
this.simulated.length +
|
|
'].'
|
|
);
|
|
} else {
|
|
for (let i = 0; i < this.extracted.length; i++) {
|
|
var extracted = this.extracted[i];
|
|
var simulated = this.simulated[i];
|
|
if (extracted != simulated) {
|
|
console.log('Events differ:' + extracted + '|' + simulated);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static selector(context) {
|
|
return OptimalSelect.select(context)
|
|
}
|
|
|
|
static reset() {
|
|
this.extracted = [];
|
|
this.simulated = [];
|
|
}
|
|
|
|
static resetSimulated() {
|
|
this.simulated = [];
|
|
}
|
|
|
|
static showExtractedEvents(event) {
|
|
if (!event.shiftKey) {
|
|
return
|
|
}
|
|
if (this.popup == null) {
|
|
let element = document.createElement('div');
|
|
Elements.setStyle(element, {
|
|
position: 'absolute',
|
|
width: '480px',
|
|
height: '640px',
|
|
overflow: 'auto',
|
|
backgroundColor: 'lightgray'
|
|
});
|
|
document.body.appendChild(element);
|
|
this.popup = element;
|
|
}
|
|
this.popup.innerHTML = '';
|
|
for (let line of this.extracted) {
|
|
let div = document.createElement('div');
|
|
div.innerHTML = line;
|
|
this.popup.appendChild(div);
|
|
}
|
|
let div = document.createElement('div');
|
|
div.innerHTML = '------------ Simulated -----------';
|
|
this.popup.appendChild(div);
|
|
for (let line of this.simulated) {
|
|
let div = document.createElement('div');
|
|
div.innerHTML = line;
|
|
this.popup.appendChild(div);
|
|
}
|
|
Elements.setStyle(this.popup, {
|
|
left: event.clientX + 'px',
|
|
top: event.clientY + 'px'
|
|
});
|
|
}
|
|
}
|
|
|
|
Events$1.popup = null;
|
|
Events$1.debug = true;
|
|
Events$1.extracted = [];
|
|
Events$1.simulated = [];
|
|
Events$1.simulationRunning = false;
|
|
|
|
/* globals */
|
|
|
|
/** Tests whether an object is empty
|
|
* @param {Object} obj - the object to be tested
|
|
* @return {boolean}
|
|
*/
|
|
function isEmpty(obj) {
|
|
// > isEmpty({})
|
|
// true
|
|
for (let i in obj) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function lerp(start, stop, amt) {
|
|
return amt * (stop - start) + start
|
|
}
|
|
|
|
// Returns a function, that, as long as it continues to be invoked, will not
|
|
// be triggered. The function will be called after it stops being called for
|
|
// N milliseconds. If `immediate` is passed, trigger the function on the
|
|
// leading edge, instead of the trailing.
|
|
// Taken from: https://davidwalsh.name/essential-javascript-functions
|
|
function debounce(func, wait, immediate) {
|
|
let timeout;
|
|
return function() {
|
|
let context = this,
|
|
args = arguments;
|
|
let later = function() {
|
|
timeout = null;
|
|
if (!immediate) func.apply(context, args);
|
|
};
|
|
let callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
if (callNow) func.apply(context, args);
|
|
}
|
|
}
|
|
|
|
/** Returns an id that is guaranteed to be unique within the livetime of the
|
|
* application
|
|
* @return {string}
|
|
*/
|
|
let _idGenerator = 0;
|
|
function getId() {
|
|
return 'id' + _idGenerator++
|
|
}
|
|
|
|
class Dates {
|
|
static create(fullYear, month, day) {
|
|
return new Date(Date.UTC(fullYear, month, day))
|
|
}
|
|
|
|
static daysInMonth(date) {
|
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
|
}
|
|
|
|
static startYearRange(date) {
|
|
return new Date(Date.UTC(date.getFullYear() - 1, 11, 31, 23, 59, 59, 999))
|
|
}
|
|
|
|
static endYearRange(date) {
|
|
return new Date(Date.UTC(date.getFullYear() + 1, 0, 1))
|
|
}
|
|
|
|
static prevYear(date, offset = 1) {
|
|
return this.create(date.getFullYear() - offset, 0, 1)
|
|
}
|
|
|
|
static nextYear(date, offset = 1) {
|
|
return this.create(date.getFullYear() + offset, 0, 1)
|
|
}
|
|
|
|
static nextMonth(date) {
|
|
return this.create(date.getFullYear(), date.getMonth() + 1, 1)
|
|
}
|
|
|
|
static nextDay(date) {
|
|
return this.create(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
|
}
|
|
|
|
static nextHour(date) {
|
|
// See http://stackoverflow.com/questions/1050720/adding-hours-to-javascript-date-object
|
|
return new Date(date.getTime() + 60 * 60 * 1000)
|
|
}
|
|
|
|
static nextMinute(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 60 * 1000)
|
|
}
|
|
|
|
static nextSecond(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 1000)
|
|
}
|
|
|
|
static nextMillisecond(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 1)
|
|
}
|
|
|
|
static *iterYears(start, end) {
|
|
let date = this.create(start.getFullYear(), 0, 1);
|
|
while (date <= end) {
|
|
yield date;
|
|
date = this.nextYear(date);
|
|
}
|
|
yield date;
|
|
}
|
|
|
|
static *iterMonths(year, limit = 12) {
|
|
let month = 0;
|
|
while (month < limit) {
|
|
let date = this.create(year.getFullYear(), month, 1);
|
|
yield date;
|
|
month += 1;
|
|
}
|
|
}
|
|
|
|
static *iterMonthsOfYears(years) {
|
|
for (let year of years) {
|
|
for (let month of this.iterMonths(year)) {
|
|
yield month;
|
|
}
|
|
}
|
|
}
|
|
|
|
static *iterDays(month) {
|
|
let day = 1;
|
|
let limit = Dates.daysInMonth(month);
|
|
while (day <= limit) {
|
|
let date = this.create(month.getFullYear(), month.getMonth(), day);
|
|
yield date;
|
|
day += 1;
|
|
}
|
|
}
|
|
|
|
static *iterDaysOfMonths(months) {
|
|
for (let month of months) {
|
|
for (let day of this.iterDays(month)) {
|
|
yield day;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/* Color conversion functions */
|
|
|
|
class Colors {
|
|
// http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
|
|
|
|
static rgb2num(red, green, blue) {
|
|
let rgb = blue | (green << 8) | (red << 16);
|
|
return 0x000000 + rgb
|
|
}
|
|
|
|
static rgb2hex(red, green, blue) {
|
|
let rgb = blue | (green << 8) | (red << 16);
|
|
return '#' + (0x1000000 + rgb).toString(16).slice(1)
|
|
}
|
|
|
|
static hex2rgb(hex) {
|
|
// long version
|
|
let r = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
if (r) {
|
|
return r.slice(1, 4).map(x => {
|
|
return parseInt(x, 16)
|
|
})
|
|
}
|
|
// short version
|
|
r = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
|
|
if (r) {
|
|
return r.slice(1, 4).map(x => {
|
|
return 0x11 * parseInt(x, 16)
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
|
|
static rgb(r, g, b) {
|
|
return { r, g, b }
|
|
}
|
|
|
|
static string2hex(str) {
|
|
return parseInt('0x' + str.slice(1))
|
|
}
|
|
|
|
static lerp(rgb1, rgb2, amount) {
|
|
return {
|
|
r: Math.round(lerp(rgb1.r, rgb2.r, amount)),
|
|
g: Math.round(lerp(rgb1.g, rgb2.g, amount)),
|
|
b: Math.round(lerp(rgb1.b, rgb2.b, amount))
|
|
}
|
|
}
|
|
|
|
static get violet() {
|
|
return Colors.rgb2num(89, 34, 131)
|
|
}
|
|
|
|
static get steelblue() {
|
|
return Colors.rgb2num(0, 130, 164)
|
|
}
|
|
|
|
static get ochre() {
|
|
return Colors.rgb2num(181, 157, 0)
|
|
}
|
|
|
|
static get turquoise() {
|
|
return Colors.rgb2num(34, 164, 131)
|
|
}
|
|
|
|
static get eminence() {
|
|
return Colors.rgb2num(150, 60, 134)
|
|
}
|
|
|
|
static random() {
|
|
let r = Math.round(Math.random() * 255);
|
|
let g = Math.round(Math.random() * 255);
|
|
let b = Math.round(Math.random() * 255);
|
|
return Colors.rgb2num(r, g, b)
|
|
}
|
|
}
|
|
|
|
class Cycle extends Array {
|
|
constructor(...items) {
|
|
super();
|
|
for (let item of items) {
|
|
this.push(item);
|
|
}
|
|
this.index = 0;
|
|
}
|
|
|
|
next() {
|
|
if (this.index == this.length) {
|
|
this.index = 0;
|
|
}
|
|
return this[this.index++]
|
|
}
|
|
|
|
current() {
|
|
if (this.index === this.length) {
|
|
this.index = 0;
|
|
}
|
|
return this[this.index]
|
|
}
|
|
}
|
|
|
|
/** Static methods to compute 2D points with x and y coordinates.
|
|
*/
|
|
class Points {
|
|
static length(a) {
|
|
return Math.sqrt(a.x * a.x + a.y * a.y)
|
|
}
|
|
|
|
static normalize(p) {
|
|
let len = this.length(p);
|
|
return this.multiplyScalar(p, 1 / len)
|
|
}
|
|
|
|
static mean(a, b) {
|
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
}
|
|
|
|
static subtract(a, b) {
|
|
return { x: a.x - b.x, y: a.y - b.y }
|
|
}
|
|
|
|
static multiply(a, b) {
|
|
return { x: a.x * b.x, y: a.y * b.y }
|
|
}
|
|
|
|
static divide(a, b) {
|
|
return { x: a.x / b.x, y: a.y / b.y }
|
|
}
|
|
|
|
static multiplyScalar(a, b) {
|
|
return { x: a.x * b, y: a.y * b }
|
|
}
|
|
|
|
static add(a, b) {
|
|
return { x: a.x + b.x, y: a.y + b.y }
|
|
}
|
|
|
|
static negate(p) {
|
|
return { x: -p.x, y: -p.y }
|
|
}
|
|
|
|
static angle(p1, p2) {
|
|
return Math.atan2(p1.y - p2.y, p1.x - p2.x)
|
|
}
|
|
|
|
static normalizedAngle(p1, p2) {
|
|
return Angle.normalize(this.angle(p1, p2))
|
|
}
|
|
|
|
static normalized2Angle(p1, p2) {
|
|
return Angle.normalize2(this.angle(p1, p2))
|
|
}
|
|
|
|
static arc(p, alpha, radius) {
|
|
return {
|
|
x: p.x + radius * Math.cos(alpha),
|
|
y: p.y + radius * Math.sin(alpha)
|
|
}
|
|
}
|
|
|
|
static distance(a, b) {
|
|
let dx = a.x - b.x;
|
|
let dy = a.y - b.y;
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
}
|
|
|
|
// Distance == 0.0 indicates an inside relation.
|
|
static distanceToRect(p, r) {
|
|
let cx = Math.max(Math.min(p.x, r.x + r.width), r.x);
|
|
let cy = Math.max(Math.min(p.y, r.y + r.height), r.y);
|
|
return Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
|
|
}
|
|
|
|
static fromPageToNode(element, p) {
|
|
// if (window.webkitConvertPointFromPageToNode) {
|
|
// return window.webkitConvertPointFromPageToNode(element,
|
|
// new WebKitPoint(p.x, p.y))
|
|
// }
|
|
return window.convertPointFromPageToNode(element, p.x, p.y)
|
|
}
|
|
|
|
static fromNodeToPage(element, p) {
|
|
// if (window.webkitConvertPointFromNodeToPage) {
|
|
// return window.webkitConvertPointFromNodeToPage(element,
|
|
// new WebKitPoint(p.x, p.y))
|
|
// }
|
|
return window.convertPointFromNodeToPage(element, p.x, p.y)
|
|
}
|
|
}
|
|
|
|
/** Static methods to compute angles.
|
|
*/
|
|
class Angle {
|
|
static normalize(angle) {
|
|
let TAU = Math.PI * 2.0;
|
|
while (angle > Math.PI) {
|
|
angle -= TAU;
|
|
}
|
|
while (angle < -Math.PI) {
|
|
angle += TAU;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalize2(angle) {
|
|
let TAU = Math.PI * 2.0;
|
|
while (angle > TAU) {
|
|
angle -= TAU;
|
|
}
|
|
while (angle < 0) {
|
|
angle += TAU;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalizeDegree(angle) {
|
|
let full = 360.0;
|
|
while (angle > 180.0) {
|
|
angle -= full;
|
|
}
|
|
while (angle < -180.0) {
|
|
angle += full;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalizedDiff(a, b) {
|
|
return this.normalize(this.diff(a, b))
|
|
}
|
|
|
|
static normalized2Diff(a, b) {
|
|
return this.normalize2(this.diff(a, b))
|
|
}
|
|
|
|
static diff(a, b) {
|
|
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
|
}
|
|
|
|
static degree2radian(degree) {
|
|
return (Math.PI * degree) / 180.0
|
|
}
|
|
|
|
static radian2degree(rad) {
|
|
return (180.0 / Math.PI) * rad
|
|
}
|
|
}
|
|
|
|
class Elements$1 {
|
|
static setStyle(element, styles) {
|
|
for (let key in styles) {
|
|
element.style[key] = styles[key];
|
|
}
|
|
}
|
|
|
|
static addClass(element, cssClass) {
|
|
element.classList.add(cssClass);
|
|
}
|
|
|
|
static removeClass(element, cssClass) {
|
|
element.classList.remove(cssClass);
|
|
}
|
|
|
|
static toggleClass(element, cssClass) {
|
|
element.classList.toggle(cssClass);
|
|
}
|
|
|
|
static hasClass(element, cssClass) {
|
|
return element.classList.contains(cssClass)
|
|
}
|
|
}
|
|
|
|
class MapProxy {
|
|
/* This class is needed if we want to use the interaction classes
|
|
in Firefox 45.8 and modern Browsers.
|
|
|
|
A workaround for https://github.com/babel/babel/issues/2334
|
|
*/
|
|
constructor() {
|
|
this.map = new Map();
|
|
}
|
|
|
|
get size() {
|
|
return this.map.size
|
|
}
|
|
|
|
get(key) {
|
|
return this.map.get(key)
|
|
}
|
|
|
|
set(key, value) {
|
|
return this.map.set(key, value)
|
|
}
|
|
|
|
delete(key) {
|
|
return this.map.delete(key)
|
|
}
|
|
|
|
clear() {
|
|
return this.map.clear()
|
|
}
|
|
|
|
has(key) {
|
|
return this.map.has(key)
|
|
}
|
|
|
|
keys() {
|
|
return this.map.keys()
|
|
}
|
|
|
|
values() {
|
|
return this.map.values()
|
|
}
|
|
|
|
entries() {
|
|
return this.map.entries()
|
|
}
|
|
|
|
forEach(func) {
|
|
this.map.forEach(func);
|
|
}
|
|
}
|
|
|
|
/* Based om https://gist.github.com/cwleonard/e124d63238bda7a3cbfa */
|
|
class Polygon {
|
|
/*
|
|
* This is the Polygon constructor. All points are center-relative.
|
|
*/
|
|
constructor(center) {
|
|
this.points = new Array();
|
|
this.center = center;
|
|
}
|
|
|
|
/*
|
|
* Point x and y values should be relative to the center.
|
|
*/
|
|
addPoint(p) {
|
|
this.points.push(p);
|
|
}
|
|
|
|
/*
|
|
* Point x and y values should be absolute coordinates.
|
|
*/
|
|
addAbsolutePoint(p) {
|
|
this.points.push({ x: p.x - this.center.x, y: p.y - this.center.y });
|
|
}
|
|
|
|
/*
|
|
* Returns the number of sides. Equal to the number of vertices.
|
|
*/
|
|
getNumberOfSides() {
|
|
return this.points.length
|
|
}
|
|
|
|
/*
|
|
* rotate the polygon by a number of radians
|
|
*/
|
|
rotate(rads) {
|
|
for (let i = 0; i < this.points.length; i++) {
|
|
let x = this.points[i].x;
|
|
let y = this.points[i].y;
|
|
this.points[i].x = Math.cos(rads) * x - Math.sin(rads) * y;
|
|
this.points[i].y = Math.sin(rads) * x + Math.cos(rads) * y;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The draw function takes as a parameter a Context object from
|
|
* a Canvas element and draws the polygon on it.
|
|
*/
|
|
draw(context, { lineWidth = 2, stroke = '#000000', fill = null } = {}) {
|
|
context.beginPath();
|
|
context.moveTo(this.points[0].x + this.center.x, this.points[0].y + this.center.y);
|
|
for (let i = 1; i < this.points.length; i++) {
|
|
context.lineTo(this.points[i].x + this.center.x, this.points[i].y + this.center.y);
|
|
}
|
|
context.closePath();
|
|
context.lineWidth = lineWidth;
|
|
if (stroke) {
|
|
context.strokeStyle = stroke;
|
|
context.stroke();
|
|
}
|
|
if (fill) {
|
|
context.fillStyle = fill;
|
|
context.fill();
|
|
}
|
|
}
|
|
|
|
absolutePoints() {
|
|
let result = new Array();
|
|
for (let p of this.points) {
|
|
result.push(Points.add(p, this.center));
|
|
}
|
|
return result
|
|
}
|
|
|
|
flatAbsolutePoints() {
|
|
let result = new Array();
|
|
for (let p of this.points) {
|
|
let a = Points.add(p, this.center);
|
|
result.push(a.x);
|
|
result.push(a.y);
|
|
}
|
|
return result
|
|
}
|
|
|
|
/*
|
|
* This function returns true if the given point is inside the polygon,
|
|
* and false otherwise.
|
|
*/
|
|
containsPoint(pnt) {
|
|
let nvert = this.points.length;
|
|
let testx = pnt.x;
|
|
let testy = pnt.y;
|
|
|
|
let vertx = new Array();
|
|
for (let q = 0; q < this.points.length; q++) {
|
|
vertx.push(this.points[q].x + this.center.x);
|
|
}
|
|
|
|
let verty = new Array();
|
|
for (let w = 0; w < this.points.length; w++) {
|
|
verty.push(this.points[w].y + this.center.y);
|
|
}
|
|
|
|
let i,
|
|
j = 0;
|
|
let c = false;
|
|
for (i = 0, j = nvert - 1; i < nvert; j = i++) {
|
|
if (
|
|
verty[i] > testy != verty[j] > testy &&
|
|
testx < ((vertx[j] - vertx[i]) * (testy - verty[i])) / (verty[j] - verty[i]) + vertx[i]
|
|
)
|
|
c = !c;
|
|
}
|
|
return c
|
|
}
|
|
|
|
multiplyScalar(scale) {
|
|
let center = Points.multiplyScalar(this.center, scale);
|
|
let clone = new Polygon(center);
|
|
for (let p of this.points) {
|
|
clone.addPoint(Points.multiplyScalar(p, scale));
|
|
}
|
|
return clone
|
|
}
|
|
|
|
/*
|
|
* To detect intersection with another Polygon object, this
|
|
* function uses the Separating Axis Theorem. It returns false
|
|
* if there is no intersection, or an object if there is. The object
|
|
* contains 2 fields, overlap and axis. Moving the polygon by overlap
|
|
* on axis will get the polygons out of intersection.
|
|
*/
|
|
intersectsWith(other) {
|
|
let axis = { x: 0, y: 0 };
|
|
let tmp, minA, maxA, minB, maxB;
|
|
let side, i;
|
|
let smallest = null;
|
|
let overlap = 99999999;
|
|
|
|
/* test polygon A's sides */
|
|
for (side = 0; side < this.getNumberOfSides(); side++) {
|
|
/* get the axis that we will project onto */
|
|
if (side == 0) {
|
|
axis.x = this.points[this.getNumberOfSides() - 1].y - this.points[0].y;
|
|
axis.y = this.points[0].x - this.points[this.getNumberOfSides() - 1].x;
|
|
} else {
|
|
axis.x = this.points[side - 1].y - this.points[side].y;
|
|
axis.y = this.points[side].x - this.points[side - 1].x;
|
|
}
|
|
|
|
/* normalize the axis */
|
|
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
|
|
axis.x /= tmp;
|
|
axis.y /= tmp;
|
|
|
|
/* project polygon A onto axis to determine the min/max */
|
|
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
|
|
for (i = 1; i < this.getNumberOfSides(); i++) {
|
|
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
|
|
if (tmp > maxA) maxA = tmp;
|
|
else if (tmp < minA) minA = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = this.center.x * axis.x + this.center.y * axis.y;
|
|
minA += tmp;
|
|
maxA += tmp;
|
|
|
|
/* project polygon B onto axis to determine the min/max */
|
|
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
|
|
for (i = 1; i < other.getNumberOfSides(); i++) {
|
|
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
|
|
if (tmp > maxB) maxB = tmp;
|
|
else if (tmp < minB) minB = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = other.center.x * axis.x + other.center.y * axis.y;
|
|
minB += tmp;
|
|
maxB += tmp;
|
|
|
|
/* test if lines intersect, if not, return false */
|
|
if (maxA < minB || minA > maxB) {
|
|
return false
|
|
} else {
|
|
let o = maxA > maxB ? maxB - minA : maxA - minB;
|
|
if (o < overlap) {
|
|
overlap = o;
|
|
smallest = { x: axis.x, y: axis.y };
|
|
}
|
|
}
|
|
}
|
|
|
|
/* test polygon B's sides */
|
|
for (side = 0; side < other.getNumberOfSides(); side++) {
|
|
/* get the axis that we will project onto */
|
|
if (side == 0) {
|
|
axis.x = other.points[other.getNumberOfSides() - 1].y - other.points[0].y;
|
|
axis.y = other.points[0].x - other.points[other.getNumberOfSides() - 1].x;
|
|
} else {
|
|
axis.x = other.points[side - 1].y - other.points[side].y;
|
|
axis.y = other.points[side].x - other.points[side - 1].x;
|
|
}
|
|
|
|
/* normalize the axis */
|
|
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
|
|
axis.x /= tmp;
|
|
axis.y /= tmp;
|
|
|
|
/* project polygon A onto axis to determine the min/max */
|
|
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
|
|
for (i = 1; i < this.getNumberOfSides(); i++) {
|
|
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
|
|
if (tmp > maxA) maxA = tmp;
|
|
else if (tmp < minA) minA = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = this.center.x * axis.x + this.center.y * axis.y;
|
|
minA += tmp;
|
|
maxA += tmp;
|
|
|
|
/* project polygon B onto axis to determine the min/max */
|
|
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
|
|
for (i = 1; i < other.getNumberOfSides(); i++) {
|
|
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
|
|
if (tmp > maxB) maxB = tmp;
|
|
else if (tmp < minB) minB = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = other.center.x * axis.x + other.center.y * axis.y;
|
|
minB += tmp;
|
|
maxB += tmp;
|
|
|
|
/* test if lines intersect, if not, return false */
|
|
if (maxA < minB || minA > maxB) {
|
|
return false
|
|
} else {
|
|
let o = maxA > maxB ? maxB - minA : maxA - minB;
|
|
if (o < overlap) {
|
|
overlap = o;
|
|
smallest = { x: axis.x, y: axis.y };
|
|
}
|
|
}
|
|
}
|
|
return { overlap: overlap + 0.001, axis: smallest }
|
|
}
|
|
|
|
static fromPoints(points) {
|
|
let min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE };
|
|
let max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE };
|
|
for (let p of points) {
|
|
min.x = Math.min(p.x, min.x);
|
|
max.x = Math.max(p.x, max.x);
|
|
min.y = Math.min(p.y, min.y);
|
|
max.y = Math.max(p.y, max.y);
|
|
}
|
|
let center = Points.mean(min, max);
|
|
let polygon = new Polygon(center);
|
|
for (let p of points) {
|
|
polygon.addAbsolutePoint(p);
|
|
}
|
|
return polygon
|
|
}
|
|
}
|
|
|
|
class LowPassFilter {
|
|
constructor(smoothing = 0.5, bufferMaxSize = 10) {
|
|
this.smoothing = smoothing; // must be smaller than 1
|
|
this.buffer = []; // FIFO queue
|
|
this.bufferMaxSize = bufferMaxSize;
|
|
}
|
|
|
|
/**
|
|
* Setup buffer with array of values
|
|
*
|
|
* @param {array} values
|
|
* @returns {array}
|
|
* @access public
|
|
*/
|
|
setup(values) {
|
|
for (let i = 0; i < values.length; i++) {
|
|
this.__push(values[i]);
|
|
}
|
|
return this.buffer
|
|
}
|
|
|
|
/**
|
|
* Clear buffer to prepare for new values.
|
|
*
|
|
* @access public
|
|
*/
|
|
clear() {
|
|
this.buffer = [];
|
|
}
|
|
|
|
/**
|
|
* Add new value to buffer (FIFO queue)
|
|
*
|
|
* @param {integer|float} value
|
|
* @returns {integer|float}
|
|
* @access private
|
|
*/
|
|
__push(value) {
|
|
let removed = this.buffer.length === this.bufferMaxSize ? this.buffer.shift() : 0;
|
|
|
|
this.buffer.push(value);
|
|
return removed
|
|
}
|
|
|
|
/**
|
|
* Smooth value from stream
|
|
*
|
|
* @param {integer|float} nextValue
|
|
* @returns {integer|float}
|
|
* @access public
|
|
*/
|
|
next(nextValue) {
|
|
// push new value to the end, and remove oldest one
|
|
let removed = this.__push(nextValue);
|
|
// smooth value using all values from buffer
|
|
let result = this.buffer.reduce((last, current) => {
|
|
return this.smoothing * current + (1 - this.smoothing) * last
|
|
}, removed);
|
|
// replace smoothed value
|
|
this.buffer[this.buffer.length - 1] = result;
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Smooth array of values
|
|
*
|
|
* @param {array} values
|
|
* @returns {undefined}
|
|
* @access public
|
|
*/
|
|
smoothArray(values) {
|
|
let value = values[0];
|
|
for (let i = 1; i < values.length; i++) {
|
|
let currentValue = values[i];
|
|
value += (currentValue - value) * this.smoothing;
|
|
values[i] = Math.round(value);
|
|
}
|
|
return values
|
|
}
|
|
}
|
|
|
|
/* global */
|
|
|
|
/**
|
|
* Callback for the button action.
|
|
*
|
|
* @callback actionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the button beforeAction.
|
|
*
|
|
* @callback beforeActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the button afterAction.
|
|
*
|
|
* @callback afterActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Class that represents a PixiJS Button.
|
|
*
|
|
* @example
|
|
* // Create the button
|
|
* const button = new Button({
|
|
* label: 'My Button',
|
|
* action: () => console.log('Button was clicked')
|
|
* })
|
|
*
|
|
* // Add the button to a DisplayObject
|
|
* app.scene.addChild(button)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/button.html|DocTest}
|
|
*/
|
|
class Button$1 extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Button.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the button.
|
|
* @param {number} [opts.id=auto generated] - The id of the button.
|
|
* @param {string} [opts.label] - The label of the button.
|
|
* @param {number} [opts.x=0] - The x position of the button. Can be also set after creation with button.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the button. Can be also set after creation with button.y = 0.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this button. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.minWidth=44] - The minimum width of the button.
|
|
* @param {number} [opts.minHeight=44] - The minimum height of the button.
|
|
* @param {number} [opts.padding=Theme.padding] - The inner spacing (distance from icon and/or label) to the border.
|
|
* @param {string|PIXI.DisplayObject} [opts.icon] - The icon of the button. Can be a predefined one, an URL or an PIXI.DisplayObject.
|
|
* @param {string|PIXI.DisplayObject} [opts.iconActive=icon] - The icon of the button when activated. Can be a predefined one, an URL or an PIXI.DisplayObject.
|
|
* @param {string} [opts.iconPosition=left] - The position of the icon in relation to the label. Can be left or right.
|
|
* @param {number} [opts.iconColor=Theme.iconColor] - The color of the icon (set by the tint property) as a hex value.
|
|
* @param {number} [opts.iconColorActive=Theme.iconColorActive] - The color of the icon when activated.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the button background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.fillActive=Theme.fillActive] - The color of the button background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
|
|
* @param {object} [opts.textStyle=Theme.textStyle] - A textstyle object for the styling of the label. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {number} [opts.textStyleActive=Theme.textStyleActive] - A textstyle object for the styling of the label when the
|
|
* button is activated. See PIXI.TextStyle for possible options.
|
|
* @param {number} [opts.textAlpha=Theme.textAlpha] - The alpha value of the text.
|
|
* @param {number} [opts.textActiveAlpha=Theme.textActiveAlpha] - The alpha value of the text when activated.
|
|
* @param {string} [opts.style=default] - A shortcut for styling options. Possible values are default, link.
|
|
* @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the button (which is a rounded rectangle).
|
|
* @param {boolean} [opts.disabled=false] - Is the button disabled? When disabled, the button has a lower alpha value
|
|
* and cannot be clicked (interactive is set to false).
|
|
* @param {boolean} [opts.active=false] - Is the button initially active?
|
|
* @param {actionCallback} [opts.action] - Executed when the button was triggered (by pointerup).
|
|
* @param {beforeActionCallback} [opts.beforeAction] - Executed before the main action is triggered.
|
|
* @param {afterActionCallback} [opts.afterAction] - Executed after the main action was triggered.
|
|
* @param {string} [opts.type=default] - The type of the button. Can be default or checkbox. When the type is
|
|
* checkbox, the active state is toggled automatically.
|
|
* @param {string} [opts.align=center] - The horizontal position of the label and the icon. Possible values are
|
|
* left, center and right. Only affects the style when the minWidth is bigger than the width of the icon and label.
|
|
* @param {string} [opts.verticalAlign=middle] - The vertical position of the label and the icon. Possible values are
|
|
* top, middle and button. Only affects the style when the minHeight is bigger than the height of the icon and label.
|
|
* @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
|
|
* to display.
|
|
* @param {string|object} [opts.badge] - A string for the label of the badge or an object to configure the badge to display.
|
|
* If the parameter is an object, all badge options can be set plus the following:
|
|
* @param {string} [opts.badge.align=right] - The horizontal alignment of the badge. Possible values: left, center, right
|
|
* @param {string} [opts.badge.verticalAlign=top] - The vertical alignment of the badge. Possible values: top, middle, bottom
|
|
* @param {number} [opts.badge.offsetLeft=0] - The horizontal shift of the badge.
|
|
* @param {number} [opts.badge.offsetTop=0] - The vertical shift of the badge.
|
|
* @param {boolean} [opts.visible=true] - Is the button initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
label: null,
|
|
x: 0,
|
|
y: 0,
|
|
minWidth: 44,
|
|
minHeight: 44,
|
|
padding: theme.padding,
|
|
icon: undefined,
|
|
iconActive: undefined,
|
|
iconPosition: 'left',
|
|
iconColor: theme.iconColor,
|
|
iconColorActive: theme.iconColorActive,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
fillActive: theme.fillActive,
|
|
fillActiveAlpha: theme.fillActiveAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
strokeActive: theme.strokeActive,
|
|
strokeActiveWidth: theme.strokeActiveWidth,
|
|
strokeActiveAlpha: theme.strokeActiveAlpha,
|
|
textStyle: {},
|
|
textStyleActive: {},
|
|
textAlpha: theme.textAlpha,
|
|
textActiveAlpha: theme.textActiveAlpha,
|
|
style: 'default',
|
|
radius: theme.radius,
|
|
disabled: false,
|
|
active: false,
|
|
action: null,
|
|
beforeAction: null,
|
|
afterAction: null,
|
|
type: 'default',
|
|
align: 'center',
|
|
verticalAlign: 'middle',
|
|
tooltip: null,
|
|
badge: null,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
this.opts.textStyle = Object.assign({}, theme.textStyle, this.opts.textStyle);
|
|
this.opts.textStyleActive = Object.assign({}, theme.textStyleActive, this.opts.textStyleActive);
|
|
|
|
if (typeof this.opts.icon === 'undefined' && typeof this.opts.iconActive !== 'undefined') {
|
|
this.opts.icon = this.opts.iconActive;
|
|
} else if (typeof this.opts.icon !== 'undefined' && typeof this.opts.iconActive === 'undefined') {
|
|
this.opts.iconActive = this.opts.icon;
|
|
}
|
|
|
|
if (this.opts.style === 'link') {
|
|
Object.assign(this.opts, {
|
|
strokeAlpha: 0,
|
|
strokeActiveAlpha: 0,
|
|
fillAlpha: 0,
|
|
fillActiveAlpha: 0
|
|
});
|
|
}
|
|
|
|
this._active = null;
|
|
this._disabled = null;
|
|
|
|
this.__start = { x: null, y: null };
|
|
|
|
this.iconInactive = null;
|
|
this.iconActive = null;
|
|
this.text = null;
|
|
|
|
this.button = null;
|
|
this.content = null;
|
|
|
|
this.tooltip = null;
|
|
this.badge = null;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
}
|
|
|
|
/**
|
|
* Captures an event to inform InteractionMapper about processed events.
|
|
*
|
|
* @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
|
|
*/
|
|
capture(event) {
|
|
Events$1.capturedBy(event.data.originalEvent, this);
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
setup() {
|
|
// Button
|
|
//-----------------
|
|
let button = new PIXI.Graphics();
|
|
this.button = button;
|
|
this.addChild(button);
|
|
|
|
// Content
|
|
//-----------------
|
|
let content = new PIXI.Container();
|
|
this.content = content;
|
|
this.addChild(content);
|
|
|
|
// Text
|
|
//-----------------
|
|
if (this.opts.label) {
|
|
this.text = new PIXI.Text(this.opts.label, this.opts.textStyle);
|
|
}
|
|
|
|
// Icon
|
|
//-----------------
|
|
if (this.opts.icon) {
|
|
this.iconInactive = this.loadIcon(this.opts.icon, this.opts.iconColor);
|
|
}
|
|
|
|
if (this.opts.iconActive) {
|
|
this.iconActive = this.loadIcon(this.opts.iconActive, this.opts.iconColorActive);
|
|
}
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.button.on('pointerover', e => {
|
|
this.capture(e);
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.83,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
|
|
this.button.on('pointermove', e => {
|
|
this.capture(e);
|
|
});
|
|
|
|
this.button.on('pointerout', this.onEnd.bind(this));
|
|
this.button.on('pointercancel', this.onEnd.bind(this));
|
|
this.button.on('pointerupoutside', this.onEnd.bind(this));
|
|
this.button.on('pointertap', this.onEnd.bind(this));
|
|
this.button.on('scroll', this.onEnd.bind(this));
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
this.button.on('pointerdown', e => {
|
|
//this.capture(e)
|
|
this.__start.x = e.data.global.x;
|
|
this.__start.y = e.data.global.y;
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.7,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
|
|
this.button.on('pointerup', e => {
|
|
this.capture(e);
|
|
|
|
const distance = Points.distance(e.data.global, this.__start);
|
|
|
|
if (distance < 5) {
|
|
if (this.opts.beforeAction) {
|
|
this.opts.beforeAction.call(this, e, this);
|
|
}
|
|
|
|
if (this.opts.action) {
|
|
this.opts.action.call(this, e, this);
|
|
}
|
|
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.83,
|
|
overwrite: 'none'
|
|
});
|
|
|
|
if (this.opts.type === 'checkbox') {
|
|
this.active = !this.active;
|
|
}
|
|
|
|
if (this.opts.afterAction) {
|
|
this.opts.afterAction.call(this, e, this);
|
|
}
|
|
}
|
|
});
|
|
|
|
// disabled
|
|
//-----------------
|
|
this.disabled = this.opts.disabled;
|
|
|
|
// active
|
|
//-----------------
|
|
this.active = this.opts.active; // calls .layout()
|
|
|
|
// tooltip
|
|
//-----------------
|
|
if (this.opts.tooltip) {
|
|
if (typeof this.opts.tooltip === 'string') {
|
|
this.tooltip = new Tooltip({
|
|
object: this,
|
|
content: this.opts.tooltip
|
|
});
|
|
} else {
|
|
this.opts.tooltip = Object.assign({}, { object: this }, this.opts.tooltip);
|
|
this.tooltip = new Tooltip(this.opts.tooltip);
|
|
}
|
|
}
|
|
|
|
// badge
|
|
//-----------------
|
|
if (this.opts.badge) {
|
|
let opts = Object.assign(
|
|
{},
|
|
{
|
|
align: 'right',
|
|
verticalAlign: 'top',
|
|
offsetLeft: 0,
|
|
offsetTop: 0
|
|
}
|
|
);
|
|
if (typeof this.opts.badge === 'string') {
|
|
opts = Object.assign(opts, { content: this.opts.badge });
|
|
} else {
|
|
opts = Object.assign(opts, this.opts.badge);
|
|
}
|
|
|
|
const badge = new Badge(opts);
|
|
this.addChild(badge);
|
|
this.badge = badge;
|
|
}
|
|
|
|
this.layout();
|
|
|
|
// set position
|
|
//-----------------
|
|
this.position.set(this.opts.x, this.opts.y);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the button. Can be used after resizing.
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layout() {
|
|
// Clear content
|
|
//-----------------
|
|
this.removeChild(this.content);
|
|
this.content = new PIXI.Container();
|
|
this.addChild(this.content);
|
|
|
|
// Set the icon
|
|
//-----------------
|
|
let icon = null;
|
|
|
|
if (!this.active && this.iconInactive) {
|
|
icon = this.iconInactive;
|
|
} else if (this.active && this.iconActive) {
|
|
icon = this.iconActive;
|
|
}
|
|
|
|
// Set the text
|
|
//-----------------
|
|
if (this.text) {
|
|
this.text.position.set(0, 0);
|
|
}
|
|
|
|
// Width and Height
|
|
//-----------------
|
|
let width = 0;
|
|
if (icon && this.text) {
|
|
width = icon.width + this.text.width + 3 * this.opts.padding;
|
|
} else if (icon) {
|
|
width = icon.width + 2 * this.opts.padding;
|
|
} else if (this.text) {
|
|
width = this.text.width + 2 * this.opts.padding;
|
|
}
|
|
|
|
if (width < this.opts.minWidth) {
|
|
width = this.opts.minWidth;
|
|
}
|
|
|
|
let height = 0;
|
|
if (icon) {
|
|
height = icon.height + 2 * this.opts.padding;
|
|
} else if (this.text) {
|
|
height = this.text.height + 2 * this.opts.padding;
|
|
}
|
|
|
|
if (height < this.opts.minHeight) {
|
|
height = this.opts.minHeight;
|
|
}
|
|
|
|
this._width = width;
|
|
this._height = height;
|
|
|
|
// Position icon and text
|
|
//-----------------
|
|
if (icon && this.text) {
|
|
if (this.opts.iconPosition === 'right') {
|
|
icon.x = this.text.width + this.opts.padding;
|
|
} else {
|
|
this.text.x = icon.width + this.opts.padding;
|
|
}
|
|
this.content.addChild(icon, this.text);
|
|
} else if (icon) {
|
|
this.content.addChild(icon);
|
|
} else if (this.text) {
|
|
this.content.addChild(this.text);
|
|
}
|
|
|
|
this.layoutInnerContent();
|
|
this.layoutContent();
|
|
|
|
this.icon = icon;
|
|
|
|
// badge
|
|
//--------------------
|
|
if (this.badge) {
|
|
this.removeChild(this.badge);
|
|
const width = this._width;
|
|
const height = this._height;
|
|
this.addChild(this.badge);
|
|
|
|
const badge = this.badge;
|
|
|
|
switch (badge.opts.align) {
|
|
case 'left':
|
|
badge.x = -badge.width / 2 + badge.opts.offsetLeft;
|
|
break
|
|
case 'center':
|
|
badge.x = width / 2 - badge.width / 2 + badge.opts.offsetLeft;
|
|
break
|
|
case 'right':
|
|
badge.x = width - badge.width / 2 + badge.opts.offsetLeft;
|
|
}
|
|
|
|
switch (badge.opts.verticalAlign) {
|
|
case 'top':
|
|
badge.y = -badge.height / 2 + badge.opts.offsetTop;
|
|
break
|
|
case 'middle':
|
|
badge.y = height / 2 - badge.height / 2 + badge.opts.offsetTop;
|
|
break
|
|
case 'bottom':
|
|
badge.y = height - badge.height / 2 + badge.opts.offsetTop;
|
|
}
|
|
}
|
|
|
|
// draw
|
|
//-----------------
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates the positions of the content children (icon and/or text).
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layoutInnerContent() {
|
|
for (let child of this.content.children) {
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
child.y = 0;
|
|
break
|
|
case 'middle':
|
|
child.y = this.content.height / 2 - child.height / 2;
|
|
break
|
|
case 'bottom':
|
|
child.y = this.content.height - child.height;
|
|
break
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Sets the horizontal and vertical position of the content.
|
|
* Uses the option keys "align" and "verticalAlign".
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layoutContent() {
|
|
switch (this.opts.align) {
|
|
case 'left':
|
|
this.content.x = this.opts.padding;
|
|
break
|
|
case 'center':
|
|
this.content.x = (this._width - this.content.width) / 2;
|
|
break
|
|
case 'right':
|
|
this.content.x = this._width - this.opts.padding - this.content.width;
|
|
break
|
|
}
|
|
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
this.content.y = this.opts.padding;
|
|
break
|
|
case 'middle':
|
|
this.content.y = (this._height - this.content.height) / 2;
|
|
break
|
|
case 'bottom':
|
|
this.content.y = this._height - this.opts.padding - this.content.height;
|
|
break
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the canvas.
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
draw() {
|
|
this.button.clear();
|
|
if (this.active) {
|
|
this.button.lineStyle(this.opts.strokeActiveWidth, this.opts.strokeActive, this.opts.strokeActiveAlpha);
|
|
this.button.beginFill(this.opts.fillActive, this.opts.fillActiveAlpha);
|
|
} else {
|
|
this.button.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.button.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
}
|
|
this.button.drawRoundedRect(0, 0, this._width, this._height, this.opts.radius);
|
|
this.button.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the active state.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get active() {
|
|
return this._active
|
|
}
|
|
set active(value) {
|
|
this._active = value;
|
|
|
|
if (this._active) {
|
|
if (this.text) {
|
|
this.text.style = this.opts.textStyleActive;
|
|
this.text.alpha = this.opts.textActiveAlpha;
|
|
}
|
|
} else {
|
|
if (this.text) {
|
|
this.text.style = this.opts.textStyle;
|
|
this.text.alpha = this.opts.textAlpha;
|
|
}
|
|
}
|
|
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the disabled state. When disabled, the button cannot be clicked.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get disabled() {
|
|
return this._disabled
|
|
}
|
|
set disabled(value) {
|
|
this._disabled = value;
|
|
|
|
if (this._disabled) {
|
|
this.button.interactive = false;
|
|
this.button.buttonMode = false;
|
|
this.button.alpha = 0.5;
|
|
if (this.icon) {
|
|
this.icon.alpha = 0.5;
|
|
}
|
|
if (this.text) {
|
|
this.text.alpha = 0.5;
|
|
}
|
|
} else {
|
|
this.button.interactive = true;
|
|
this.button.buttonMode = true;
|
|
this.button.alpha = 1;
|
|
if (this.icon) {
|
|
this.icon.alpha = 1;
|
|
}
|
|
if (this.text) {
|
|
this.text.alpha = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows the button (sets his alpha values to 1).
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
show() {
|
|
this.opts.strokeAlpha = 1;
|
|
this.opts.strokeActiveAlpha = 1;
|
|
this.opts.fillAlpha = 1;
|
|
this.opts.fillActiveAlpha = 1;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the button (sets his alpha values to 0).
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
hide() {
|
|
this.opts.strokeAlpha = 0.0;
|
|
this.opts.strokeActiveAlpha = 0.0;
|
|
this.opts.fillAlpha = 0.0000000001; // WORKAROUND: See https://github.com/pixijs/pixi.js/wiki/v5-Migration-Guide#graphics-interaction
|
|
this.opts.fillActiveAlpha = 0.0000000001;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Loads an icon
|
|
*
|
|
* @private
|
|
* @param {string|PIXI.DisplayObject} icon - The icon to load.
|
|
* @param {number} color - The color of the icon (if not an PIXI.DisplayObject).
|
|
* @return {PIXI.DisplayObject} Return the icon as an PIXI.DisplayObject.
|
|
*/
|
|
loadIcon(icon, color) {
|
|
let displayObject = null;
|
|
|
|
if (icon instanceof PIXI.DisplayObject) {
|
|
displayObject = icon;
|
|
} else {
|
|
let size = 17;
|
|
if (this.text) {
|
|
size = this.text.height;
|
|
} else if (this.opts.minHeight) {
|
|
size = this.opts.minHeight - 2 * this.opts.padding;
|
|
}
|
|
|
|
const url = Button$1.iconIsUrl(icon) ? icon : `../../assets/icons/${icon}.png`;
|
|
const iconTexture = PIXI.Texture.from(url);
|
|
|
|
const sprite = new PIXI.Sprite(iconTexture);
|
|
sprite.tint = color;
|
|
sprite.width = size;
|
|
sprite.height = size;
|
|
|
|
displayObject = sprite;
|
|
}
|
|
|
|
return displayObject
|
|
}
|
|
|
|
/**
|
|
* Tests if an icon string is an url.
|
|
*
|
|
* @private
|
|
* @static
|
|
* @param {string} url - The url to test.
|
|
* @return {boolean} true if the url is an url to an image.
|
|
*/
|
|
static iconIsUrl(url) {
|
|
return /\.(png|svg|gif|jpg|jpeg|tif|tiff)$/i.test(url)
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the color of the current icon (no matter how the status is). Changing the color, changes
|
|
* the tint property of the icon sprite.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get iconColor() {
|
|
return this.icon ? this.icon.tint : null
|
|
}
|
|
set iconColor(value) {
|
|
if (this.icon) {
|
|
this.icon.tint = value;
|
|
}
|
|
}
|
|
|
|
onEnd(event) {
|
|
this.capture(event);
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 1,
|
|
overwrite: 'none'
|
|
});
|
|
}
|
|
}
|
|
|
|
/* globals ThrowPropsPlugin, Strong */
|
|
|
|
/**
|
|
* Class that represents a PixiJS ButtonGroup.
|
|
*
|
|
* @example
|
|
* // Create the button group
|
|
* const buttonGroup = new ButtonGroup({
|
|
* buttons: [
|
|
* {label: 'Button 1', action: event => console.log(event)},
|
|
* {label: 'Button 2', action: event => console.log(event)},
|
|
* {label: 'Button 3', action: event => console.log(event)}
|
|
* ],
|
|
* minWidth: 100
|
|
* })
|
|
*
|
|
* // Add the button group to a DisplayObject
|
|
* app.scene.addChild(buttonGroup)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Graphics
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/buttongroup.html|DocTest}
|
|
*/
|
|
class ButtonGroup extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a ButtonGroup.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the button group.
|
|
* @param {number} [opts.id=auto generated] - The id of the button group.
|
|
* @param {number} [opts.x=0] - The x position of the button group. Can be also set after creation with buttonGroup.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the button group. Can be also set after creation with buttonGroup.y = 0.
|
|
* @param {object[]} [opts.buttons=[]] - An array of the buttons of the button group. One item of the array (one object)
|
|
* can have exactly the same properties as an Button object when instantiating a Button. If a property of the button group
|
|
* conflicts with a property of a button object, the value from the button object will be used.
|
|
* @param {string|Theme=} [opts.theme=dark] - The theme to use for this button group. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.minWidth=44] - Button: The minimum width of one button.
|
|
* @param {number} [opts.minHeight=44] - Button: The minimum height of one button.
|
|
* @param {number} [opts.maxWidth] - The maximum width of the button group. If the buttons are wider than the maximum width, the buttons get stacked. Note: The buttons can only be stacked if margin is not zero. Note 2: Load the Greensock ThrowPropsPlugin for smoother animations.
|
|
* @param {number} [opts.maxHeight] - The maximum height of the button group. If the buttons are higher than the maximum height, the buttons get stacked. Note: The buttons can only be stacked if margin is not zero. Note 2: Load the Greensock ThrowPropsPlugin for smoother animations.
|
|
* @param {number} [opts.stackPadding=10] - The padding for stacked buttons.
|
|
* @param {PIXI.Application} [opts.app=window.app] - The PixiJS Application. Must be set if you want to use the mousewheel to scroll your button group. Only used when the buttons are stacked (with maxWidth or maxHeight).
|
|
* @param {number} [opts.padding=Theme.padding] - Button: The inner spacing (distance from icon and/or label) the the border.
|
|
* @param {number} [opts.margin=Theme.margin] - The outer spacing (distance from one button to the previous/next button).
|
|
* @param {string} [opts.iconPosition=left] - Button: The position of the icon in relation to the label. Can be left or right.
|
|
* @param {number} [opts.iconColor=Theme.iconColor] - Button: The color of the icon (set by the tint property) as a hex value.
|
|
* @param {number} [opts.iconColorActive=Theme.iconColorActive] - Button: The color of the icon when activated.
|
|
* @param {number} [opts.fill=Theme.fill] - Button: The color of the button background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - Button: The alpha value of the background.
|
|
* @param {number} [opts.fillActive=Theme.fillActive] - Button: The color of the button background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - Button: The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=Theme.stroke] - Button: The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - Button: The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - Button: The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=Theme.strokeActive] - Button: The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - Button: The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - Button: The alpha value of the border when activated.
|
|
* @param {object} [opts.textStyle=Theme.textStyle] - Button: A textstyle object for the styling of the label. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {number} [opts.textStyleActive=Theme.textStyleActive] - Button: A textstyle object for the styling of the label when the
|
|
* button is activated. See PIXI.TextStyle for possible options.
|
|
* @param {number} [opts.textAlpha=Theme.textAlpha] - Button: The alpha value of the text.
|
|
* @param {number} [opts.textActiveAlpha=Theme.textActiveAlpha] - Button: The alpha value of the text when activated.
|
|
* @param {string} [opts.style=default] - A shortcut for styling options. Possible values are default, link.
|
|
* @param {number} [opts.radius=Theme.radius] - Button: The radius of the four corners of the button (which is a rounded rectangle).
|
|
* @param {boolean} [opts.disabled=false] - Is the button group disabled? When disabled, the button group has a lower alpha value
|
|
* and cannot be clicked (interactive of every button is set to false).
|
|
* @param {string} [opts.type=default] - The type of the button group. Can be default, checkbox or radio. When the type is
|
|
* checkbox, the active state is toggled for each button automatically. When the type is radio, only one button can
|
|
* be activated at the same time.
|
|
* @param {string} [opts.orientation=horizontal] - The orientation of the button group. Can be horizontal or vertical.
|
|
* @param {string} [opts.align=center] - Button: The horizontal position of the label and the icon. Possible values are
|
|
* left, center and right. Only affects the style when the minWidth is bigger than the width of the icon and label.
|
|
* @param {string} [opts.verticalAlign=middle] - Button: The vertical position of the label and the icon. Possible values are
|
|
* top, middle and bottom. Only affects the style when the minHeight is bigger than the height of the icon and label.
|
|
* @param {boolean} [opts.visible=true] - Is the button group initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
x: 0,
|
|
y: 0,
|
|
buttons: [],
|
|
minWidth: 44,
|
|
minHeight: 44,
|
|
maxWidth: null,
|
|
maxHeight: null,
|
|
stackPadding: 10,
|
|
app: window.app,
|
|
padding: theme.padding,
|
|
margin: theme.margin,
|
|
iconPosition: 'left', // left, right
|
|
iconColor: theme.iconColor,
|
|
iconColorActive: theme.iconColorActive,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
fillActive: theme.fillActive,
|
|
fillActiveAlpha: theme.fillActiveAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
strokeActive: theme.strokeActive,
|
|
strokeActiveWidth: theme.strokeActiveWidth,
|
|
strokeActiveAlpha: theme.strokeActiveAlpha,
|
|
textStyle: {},
|
|
textStyleActive: {},
|
|
textAlpha: theme.textAlpha,
|
|
textActiveAlpha: theme.textActiveAlpha,
|
|
style: 'default',
|
|
radius: theme.radius,
|
|
disabled: null,
|
|
type: 'default', // default, checkbox, radio
|
|
orientation: 'horizontal',
|
|
align: 'center', // left, center, right
|
|
verticalAlign: 'middle', // top, middle, bottom
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.opts.textStyle = Object.assign({}, theme.textStyle, this.opts.textStyle);
|
|
this.opts.textStyleActive = Object.assign({}, theme.textStyleActive, this.opts.textStyleActive);
|
|
|
|
this.buttons = [];
|
|
|
|
this._disabled = null;
|
|
this.__dragging = false;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {ButtonGroup} A reference to the button group for chaining.
|
|
*/
|
|
setup() {
|
|
// inner container
|
|
//--------------------
|
|
const container = new PIXI.Graphics();
|
|
this.addChild(container);
|
|
this.container = container;
|
|
|
|
// Buttons
|
|
//-----------------
|
|
let position = 0;
|
|
let index = 0;
|
|
|
|
for (let it of this.opts.buttons) {
|
|
delete it.x;
|
|
delete it.y;
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
it.x = position;
|
|
} else {
|
|
it.y = position;
|
|
}
|
|
|
|
it.theme = it.theme || this.opts.theme;
|
|
it.minWidth = it.minWidth || this.opts.minWidth;
|
|
it.minHeight = it.minHeight || this.opts.minHeight;
|
|
it.padding = it.padding || this.opts.padding;
|
|
it.iconPosition = it.iconPosition || this.opts.iconPosition;
|
|
it.iconColor = it.iconColor || this.opts.iconColor;
|
|
it.iconColorActive = it.iconColorActive || this.opts.iconColorActive;
|
|
it.fill = it.fill || this.opts.fill;
|
|
it.fillAlpha = it.fillAlpha || this.opts.fillAlpha;
|
|
it.fillActive = it.fillActive || this.opts.fillActive;
|
|
it.fillActiveAlpha = it.fillActiveAlpha || this.opts.fillActiveAlpha;
|
|
it.stroke = it.stroke || this.opts.stroke;
|
|
it.strokeWidth = it.strokeWidth != null ? it.strokeWidth : this.opts.strokeWidth;
|
|
it.strokeAlpha = it.strokeAlpha != null ? it.strokeAlpha : this.opts.strokeAlpha;
|
|
it.strokeActive = it.strokeActive || this.opts.strokeActive;
|
|
it.strokeActiveWidth = it.strokeActiveWidth != null ? it.strokeActiveWidth : this.opts.strokeActiveWidth;
|
|
it.strokeActiveAlpha = it.strokeActiveAlpha != null ? it.strokeActiveAlpha : this.opts.strokeActiveAlpha;
|
|
it.textStyle = it.textStyle || this.opts.textStyle;
|
|
it.textStyleActive = it.textStyleActive || this.opts.textStyleActive;
|
|
it.textAlpha = it.textAlpha != null ? it.textAlpha : this.opts.textAlpha;
|
|
it.textActiveAlpha = it.textActiveAlpha != null ? it.textActiveAlpha : this.opts.textActiveAlpha;
|
|
it.style = it.style || this.opts.style;
|
|
it.radius = it.radius != null ? it.radius : this.opts.radius;
|
|
if (!it.type) {
|
|
switch (this.opts.type) {
|
|
case 'checkbox':
|
|
it.type = this.opts.type;
|
|
break
|
|
default:
|
|
it.type = 'default';
|
|
break
|
|
}
|
|
}
|
|
//it.type = it.type || this.opts.type || 'default'
|
|
it.align = it.align || this.opts.align;
|
|
it.verticalAlign = it.verticalAlign || this.opts.verticalAlign;
|
|
it.afterAction = (event, button) => {
|
|
if (this.opts.type === 'radio' && button.opts.type === 'default') {
|
|
this.buttons.forEach(it => {
|
|
if (it.opts.type === 'default') {
|
|
it.active = false;
|
|
}
|
|
});
|
|
|
|
if (button.opts.type === 'default') {
|
|
button.active = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (it.tooltip) {
|
|
if (typeof it.tooltip === 'string') {
|
|
it.tooltip = { content: it.tooltip, container: this };
|
|
} else {
|
|
it.tooltip = Object.assign({}, { container: this }, it.tooltip);
|
|
}
|
|
}
|
|
|
|
let button = new Button$1(it);
|
|
|
|
this.container.addChild(button);
|
|
this.buttons.push(button);
|
|
|
|
button.__originalPosition = {
|
|
x: button.x,
|
|
y: button.y
|
|
};
|
|
|
|
position += (this.opts.orientation === 'horizontal' ? button.width : button.height) + this.opts.margin;
|
|
|
|
button.__initIndex = index;
|
|
index++;
|
|
}
|
|
|
|
if (this.opts.orientation === 'vertical') {
|
|
const maxWidth = this.getMaxButtonWidth();
|
|
|
|
this.buttons.forEach(it => {
|
|
it.opts.minWidth = maxWidth;
|
|
it.layout();
|
|
});
|
|
}
|
|
|
|
// disabled
|
|
//-----------------
|
|
if (this.opts.disabled != null) {
|
|
this.disabled = this.opts.disabled;
|
|
}
|
|
|
|
// interaction
|
|
//--------------------
|
|
if (this.opts.margin > 0 && (this.opts.maxWidth || this.opts.maxHeight)) {
|
|
this.interactive = true;
|
|
this.on('pointerdown', this.onStart.bind(this));
|
|
this.on('pointermove', this.onMove.bind(this));
|
|
this.on('pointerup', this.onEnd.bind(this));
|
|
this.on('pointercancel', this.onEnd.bind(this));
|
|
this.on('pointerout', this.onEnd.bind(this));
|
|
this.on('pointerupoutside', this.onEnd.bind(this));
|
|
this.on('scroll', this.onScroll.bind(this));
|
|
|
|
// mousewheel
|
|
//--------------------
|
|
if (this.opts.app) {
|
|
const app = this.opts.app;
|
|
app.view.addEventListener('mousewheel', event => {
|
|
const bounds = this.getBounds();
|
|
const x = event.clientX - app.view.getBoundingClientRect().left;
|
|
const y = event.clientY - app.view.getBoundingClientRect().top;
|
|
if (bounds.contains(x, y)) {
|
|
event.preventDefault();
|
|
this.emit('scroll', event);
|
|
}
|
|
});
|
|
}
|
|
|
|
const background = new PIXI.Graphics();
|
|
background.beginFill(0x000000, 0);
|
|
background.drawRect(0, 0, this.width, this.height);
|
|
background.endFill();
|
|
this.addChildAt(background, 0);
|
|
|
|
this.__initWidth = this.container.width;
|
|
this.__initHeight = this.container.height;
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the button group. Can be used after resizing.
|
|
*
|
|
* @return {ButtonGroup} A reference to the button group for chaining.
|
|
*/
|
|
layout() {
|
|
// set position
|
|
//-----------------
|
|
this.position.set(this.opts.x, this.opts.y);
|
|
|
|
// draw
|
|
//-----------------
|
|
this.draw();
|
|
|
|
// stack
|
|
//-----------------
|
|
if (this.opts.margin > 0 && (this.opts.maxWidth || this.opts.maxHeight)) {
|
|
this.stack();
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the canvas.
|
|
*
|
|
* @private
|
|
* @return {ButtonGroup} A reference to the button group for chaining.
|
|
*/
|
|
draw() {
|
|
if (this.opts.margin === 0) {
|
|
this.buttons.forEach(it => it.hide());
|
|
|
|
this.container.clear();
|
|
this.container.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.container.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
this.container.drawRoundedRect(0, 0, this.width, this.height, this.opts.radius);
|
|
|
|
// Draw borders
|
|
this.container.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha / 2);
|
|
|
|
this.buttons.forEach((it, i) => {
|
|
if (i > 0) {
|
|
this.container.moveTo(it.x, it.y);
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.container.lineTo(it.x, it.height);
|
|
} else {
|
|
this.container.lineTo(it.width, it.y);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.container.endFill();
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the disabled state. When disabled, no button of the button group can be clicked.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get disabled() {
|
|
return this._disabled
|
|
}
|
|
|
|
set disabled(value) {
|
|
this._disabled = value;
|
|
|
|
this.buttons.forEach(it => (it.disabled = value));
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the maximum width of the button group for stacking. Usefull when you want to resize the available space.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get maxWidth() {
|
|
return this.opts.maxWidth
|
|
}
|
|
|
|
set maxWidth(value) {
|
|
this.opts.maxWidth = value;
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the maximum height of the button group for stacking. Usefull when you want to resize the available space.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get maxHeight() {
|
|
return this.opts.maxHeight
|
|
}
|
|
|
|
set maxHeight(value) {
|
|
this.opts.maxHeight = value;
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Searches all buttons of the button group and returns the maximum width of one button.
|
|
*
|
|
* @private
|
|
* @return {number} The maximum with of a button of the button group.
|
|
*/
|
|
getMaxButtonWidth() {
|
|
let widths = this.buttons.map(it => it.width);
|
|
|
|
return Math.max(...widths)
|
|
}
|
|
|
|
/**
|
|
* Shows the button group (sets his alpha value to 1).
|
|
*
|
|
* @return {ButtonGroup} A reference to the button group for chaining.
|
|
*/
|
|
show() {
|
|
this.alpha = 1;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the button group (sets his alpha value to 0).
|
|
*
|
|
* @return {ButtonGroup} A reference to the button group for chaining.
|
|
*/
|
|
hide() {
|
|
this.alpha = 0;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onStart(event) {
|
|
if (
|
|
(this.opts.maxWidth != null && this.__initWidth > this.opts.maxWidth) ||
|
|
(this.opts.maxHeight != null && this.__initHeight > this.opts.maxHeight)
|
|
) {
|
|
this.__dragging = true;
|
|
|
|
this.capture(event);
|
|
|
|
this.__delta = {
|
|
x: this.container.position.x - event.data.global.x,
|
|
y: this.container.position.y - event.data.global.y
|
|
};
|
|
|
|
TweenLite.killTweensOf(this.container.position, { x: true, y: true });
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
|
ThrowPropsPlugin.track(this.container.position, 'x,y');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onMove(event) {
|
|
if (this.__dragging) {
|
|
this.capture(event);
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.container.position.x = event.data.global.x + this.__delta.x;
|
|
} else {
|
|
this.container.position.y = event.data.global.y + this.__delta.y;
|
|
}
|
|
|
|
this.stack();
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onEnd(event) {
|
|
if (this.__dragging) {
|
|
this.__dragging = false;
|
|
|
|
this.capture(event);
|
|
|
|
const throwProps = { x: { velocity: 'auto' }, y: { velocity: 'auto' } };
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
if (this.__initWidth > this.opts.maxWidth) {
|
|
// stack!
|
|
const distanceToLeft = this.container.x;
|
|
const distanceToRight = this.opts.maxWidth - this.container.x - this.__initWidth;
|
|
|
|
if (distanceToLeft > 0) {
|
|
throwProps.x.end = 0;
|
|
} else if (distanceToRight > 0) {
|
|
throwProps.x.end = this.opts.maxWidth - this.__initWidth;
|
|
}
|
|
} else {
|
|
// just magnetize
|
|
throwProps.x.end = 0;
|
|
}
|
|
} else {
|
|
if (this.__initHeight > this.opts.maxHeight) {
|
|
// stack!
|
|
const distanceToTop = this.container.y;
|
|
const distanceToBottom = this.opts.maxHeight - this.container.y - this.__initHeight;
|
|
|
|
if (distanceToTop > 0) {
|
|
throwProps.y.end = 0;
|
|
} else if (distanceToBottom > 0) {
|
|
throwProps.y.end = this.opts.maxHeight - this.__initHeight;
|
|
}
|
|
} else {
|
|
// just magnetize
|
|
throwProps.y.end = 0;
|
|
}
|
|
}
|
|
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
|
ThrowPropsPlugin.to(
|
|
this.container.position,
|
|
{
|
|
throwProps,
|
|
ease: Strong.easeOut,
|
|
onUpdate: () => this.stack(),
|
|
onComplete: () => ThrowPropsPlugin.untrack(this.container.position)
|
|
},
|
|
0.8,
|
|
0.4
|
|
);
|
|
} else {
|
|
if (this.opts.orientation === 'horizontal' && throwProps.x.end != null) {
|
|
TweenMax.to(this.container.position, 0.3, { x: throwProps.x.end, onUpdate: this.stack.bind(this) });
|
|
} else if (this.opts.orientation === 'vertical' && throwProps.y.end != null) {
|
|
TweenMax.to(this.container.position, 0.3, { y: throwProps.y.end, onUpdate: this.stack.bind(this) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onScroll(event) {
|
|
if (
|
|
(this.opts.maxWidth != null && this.__initWidth > this.opts.maxWidth) ||
|
|
(this.opts.maxHeight != null && this.__initHeight > this.opts.maxHeight)
|
|
) {
|
|
this.capture(event);
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.container.position.x -= event.deltaX;
|
|
if (this.container.position.x > 0) {
|
|
this.container.position.x = 0;
|
|
} else if (this.container.position.x + this.__initWidth < this.opts.maxWidth) {
|
|
this.container.position.x = this.opts.maxWidth - this.__initWidth;
|
|
}
|
|
} else {
|
|
this.container.position.y -= event.deltaY;
|
|
if (this.container.position.y > 0) {
|
|
this.container.position.y = 0;
|
|
} else if (this.container.position.y + this.container.height < this.opts.maxHeight) {
|
|
this.container.position.y = this.opts.maxHeight - this.container.height;
|
|
}
|
|
}
|
|
|
|
this.stack();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Captures an event to inform InteractionMapper about processed events.
|
|
*
|
|
* @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
|
|
*/
|
|
capture(event) {
|
|
const originalEvent = event.data && event.data.originalEvent ? event.data.originalEvent : event;
|
|
Events$1.capturedBy(originalEvent, this);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
stack() {
|
|
if (this.opts.maxWidth) {
|
|
this._stackHorizontal();
|
|
} else if (this.opts.maxHeight) {
|
|
this._stackVertical();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_stackHorizontal() {
|
|
const sorted = [];
|
|
|
|
let reverseCounter = this.buttons.length - 1;
|
|
|
|
this.buttons.forEach((it, index) => {
|
|
const leftCorner = it.__originalPosition.x + this.container.x;
|
|
const rightCorner = it.__originalPosition.x + it.button.width;
|
|
const paddingLeft = index * this.opts.stackPadding;
|
|
const paddingRight = reverseCounter * this.opts.stackPadding;
|
|
if (leftCorner < paddingLeft) {
|
|
// left border
|
|
it.x = -this.container.x + paddingLeft;
|
|
} else if (rightCorner > -this.container.x + this.opts.maxWidth - paddingRight) {
|
|
// right border
|
|
it.x = -this.container.x + this.opts.maxWidth - it.button.width - paddingRight;
|
|
} else {
|
|
it.x = it.__originalPosition.x;
|
|
}
|
|
|
|
reverseCounter--;
|
|
|
|
sorted.push(it);
|
|
});
|
|
|
|
const min = Math.min(...sorted.map(it => it.x));
|
|
const max = Math.max(...sorted.map(it => it.x + it.button.width));
|
|
const center = (min + max) / 2;
|
|
|
|
// z-index
|
|
sorted
|
|
.sort((a, b) => {
|
|
const centerA = a.x + a.button.width / 2;
|
|
const centerB = b.x + b.button.width / 2;
|
|
|
|
if (centerA < center && centerB < center) {
|
|
if (a.x < b.x) {
|
|
return -1
|
|
} else if (b.x < a.x) {
|
|
return 1
|
|
}
|
|
} else if (centerA > center && centerB > center) {
|
|
if (a.x + a.button.width > b.x + b.button.width) {
|
|
return -1
|
|
} else if (b.x + b.button.width < a.x + a.button.x) {
|
|
return 1
|
|
}
|
|
}
|
|
|
|
return 0
|
|
})
|
|
.forEach(it => it.parent.addChild(it));
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_stackVertical() {
|
|
const sorted = [];
|
|
|
|
let reverseCounter = this.buttons.length - 1;
|
|
|
|
this.buttons.forEach((it, index) => {
|
|
const topCorner = it.__originalPosition.y + this.container.y;
|
|
const bottomCorner = it.__originalPosition.y + it.button.height;
|
|
const paddingTop = index * this.opts.stackPadding;
|
|
const paddingBottom = reverseCounter * this.opts.stackPadding;
|
|
if (topCorner < paddingTop) {
|
|
// top border
|
|
it.y = -this.container.y + paddingTop;
|
|
} else if (bottomCorner > -this.container.y + this.opts.maxHeight - paddingBottom) {
|
|
// bottom border
|
|
it.y = -this.container.y + this.opts.maxHeight - it.button.height - paddingBottom;
|
|
} else {
|
|
it.y = it.__originalPosition.y;
|
|
}
|
|
|
|
reverseCounter--;
|
|
|
|
sorted.push(it);
|
|
});
|
|
|
|
const min = Math.min(...sorted.map(it => it.y));
|
|
const max = Math.max(...sorted.map(it => it.y + it.button.height));
|
|
const center = (min + max) / 2;
|
|
|
|
// z-index
|
|
sorted
|
|
.sort((a, b) => {
|
|
const centerA = a.y + a.button.height / 2;
|
|
const centerB = b.y + b.button.height / 2;
|
|
|
|
if (centerA < center && centerB < center) {
|
|
if (a.y < b.y) {
|
|
return -1
|
|
} else if (b.y < a.y) {
|
|
return 1
|
|
}
|
|
} else if (centerA > center && centerB > center) {
|
|
if (a.y + a.button.height > b.y + b.button.height) {
|
|
return -1
|
|
} else if (b.y + b.button.height < a.y + a.button.y) {
|
|
return 1
|
|
}
|
|
}
|
|
|
|
return 0
|
|
})
|
|
.forEach(it => it.parent.addChild(it));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS InteractivePopup.
|
|
* The class is used for various other Popup-like classes
|
|
* like Popup, Message...
|
|
*
|
|
* @class
|
|
* @abstract
|
|
* @extends AbstractPopup
|
|
*/
|
|
class InteractivePopup extends AbstractPopup {
|
|
/**
|
|
* Creates an instance of an InteractivePopup (only for internal use).
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the popup.
|
|
* @param {boolean} [opts.closeOnPopup=false] - Should the popup be closed when the user clicks on the popup?
|
|
* @param {boolean} [opts.closeButton=true] - Should a close button be displayed on the upper right corner?
|
|
* @param {object} [opts.button] - A Button object to be display on the lower right corner.
|
|
* @param {object} [opts.buttonGroup] - A ButtonGroup object to be displayed on the lower right corner.
|
|
*/
|
|
constructor(opts = {}) {
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
closeOnPopup: false,
|
|
closeButton: true,
|
|
button: null,
|
|
buttonGroup: null
|
|
},
|
|
opts
|
|
);
|
|
|
|
super(opts);
|
|
|
|
this._closeButton = null;
|
|
this._buttons = null;
|
|
|
|
// padding
|
|
this.smallPadding = this.opts.padding / 2;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates the framework and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
setup() {
|
|
super.setup();
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.on('pointerup', e => {
|
|
if (this.opts.closeOnPopup) {
|
|
this.hide();
|
|
} else {
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
// closeButton
|
|
//-----------------
|
|
if (this.opts.closeButton) {
|
|
let closeButton = PIXI.Sprite.from('../../assets/icons/close.png');
|
|
closeButton.width = this.headerStyle.fontSize;
|
|
closeButton.height = closeButton.width;
|
|
closeButton.tint = this.theme.color2;
|
|
// This is needed, because the closeButton belongs to the content. The popup must resize with the closeButton.
|
|
if (this._header) {
|
|
closeButton.x = this._header.width + this.innerPadding;
|
|
} else if (this._content) {
|
|
closeButton.x = this._content.width + this.innerPadding;
|
|
}
|
|
|
|
closeButton.interactive = true;
|
|
closeButton.buttonMode = true;
|
|
closeButton.on('pointerdown', e => {
|
|
this.hide();
|
|
});
|
|
|
|
this._closeButton = closeButton;
|
|
this.addChild(closeButton);
|
|
|
|
// maxWidth is set and a closeButton should be displayed
|
|
//-----------------
|
|
if (this.opts.maxWidth) {
|
|
const wordWrapWidth =
|
|
this.opts.maxWidth - 2 * this.opts.padding - this.smallPadding - this._closeButton.width;
|
|
if (this._header) {
|
|
this.headerStyle.wordWrapWidth = wordWrapWidth;
|
|
} else if (this._content) {
|
|
this.textStyle.wordWrapWidth = wordWrapWidth;
|
|
}
|
|
}
|
|
}
|
|
|
|
// buttons
|
|
//-----------------
|
|
if (this.opts.button || this.opts.buttonGroup) {
|
|
if (this.opts.button) {
|
|
this._buttons = new Button$1(Object.assign({ textStyle: this.theme.textStyleSmall }, this.opts.button));
|
|
} else {
|
|
this._buttons = new ButtonGroup(
|
|
Object.assign({ textStyle: this.theme.textStyleSmall }, this.opts.buttonGroup)
|
|
);
|
|
}
|
|
this.addChild(this._buttons);
|
|
|
|
this._buttons.y = this.innerPadding + this.sy;
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the popup. Can be used after resizing.
|
|
*
|
|
* @return {AbstractPopup} A reference to the popup for chaining.
|
|
*/
|
|
layout() {
|
|
super.layout();
|
|
|
|
// closeButton
|
|
//-----------------
|
|
if (this.opts.closeButton) {
|
|
this._closeButton.x = this.wantedWidth - this.smallPadding - this._closeButton.width;
|
|
this._closeButton.y = this.smallPadding;
|
|
}
|
|
|
|
// buttons
|
|
//-----------------
|
|
if (this._buttons) {
|
|
this._buttons.x = this.wantedWidth - this.opts.padding - this._buttons.width;
|
|
this._buttons.y = this.wantedHeight - this.opts.padding - this._buttons.height;
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates the size of the children of the AbstractPopup.
|
|
* Cannot use getBounds() because it is not updated when children
|
|
* are removed.
|
|
*
|
|
* @private
|
|
* @override
|
|
* @returns {object} An JavaScript object width the keys width and height.
|
|
*/
|
|
getInnerSize() {
|
|
let size = super.getInnerSize();
|
|
|
|
if (this._closeButton) {
|
|
size.width += this.smallPadding + this._closeButton.width;
|
|
}
|
|
|
|
if (this._buttons) {
|
|
size.width = Math.max(size.width, this._buttons.x + this._buttons.width);
|
|
size.height += this.innerPadding + this._buttons.height;
|
|
}
|
|
|
|
return size
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Popup.
|
|
*
|
|
* @example
|
|
* // Create the popup
|
|
* const popup = new Popup({
|
|
* header: 'Goethe',
|
|
* content: 'Man kann die Erfahrung nicht früh genug machen, wie entbehrlich man in der Welt ist.'
|
|
* })
|
|
*
|
|
* // Add the popup to a DisplayObject
|
|
* app.scene.addChild(popup)
|
|
*
|
|
* @class
|
|
* @extends InteractivePopup
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/popup.html|DocTest}
|
|
*/
|
|
class Popup extends InteractivePopup {
|
|
/**
|
|
* Creates an instance of a Popup.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the popup.
|
|
* @param {boolean} [opts.closeButton=false] - Should a close button be displayed on the upper right corner?
|
|
* @param {number} [opts.minWidth=0] - The minimum width of the popup.
|
|
* @param {number} [opts.minHeight=0] - The minimum height of the popup.
|
|
*/
|
|
constructor(opts = {}) {
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
closeButton: false,
|
|
minWidth: 0,
|
|
minHeight: 0
|
|
},
|
|
opts
|
|
);
|
|
|
|
super(opts);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Modal.
|
|
*
|
|
* @example
|
|
* // Create the button and the modal when clicked
|
|
* const button = new Button({
|
|
* label: 'Show Modal',
|
|
* action: e => {
|
|
* const modal = new Modal({
|
|
* app: app,
|
|
* header: 'This is the header',
|
|
* content: 'This is the text.'
|
|
* })
|
|
* app.scene.addChild(modal)
|
|
* }
|
|
* })
|
|
*
|
|
* // Add the button to a DisplayObject
|
|
* app.scene.addChild(button)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @extends InteractivePopup
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/modal.html|DocTest}
|
|
*/
|
|
class Modal extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Modal.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the modal.
|
|
* @param {number} [opts.id=auto generated] - The id of the modal.
|
|
* @param {PIXIApp} [opts.app=window.app] - The app where the modal belongs to.
|
|
* @param {number} [opts.backgroundFill=Theme.background] - The color of the background.
|
|
* @param {number} [opts.backgroundFillAlpha=0.6] - The opacity of the background.
|
|
* @param {boolean} [opts.closeOnBackground=true] - Should the modal be closed when the user clicks the
|
|
* background?
|
|
* @param {boolean} [opts.visible=true] - Is the modal initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
app: window.app,
|
|
backgroundFill: theme.background,
|
|
backgroundFillAlpha: 0.6,
|
|
closeOnBackground: true,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
this.background = null;
|
|
this.popup = null;
|
|
|
|
this.alpha = 0;
|
|
this.visible = this.opts.visible;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Modal} A reference to the modal for chaining.
|
|
*/
|
|
setup() {
|
|
// interaction
|
|
//-----------------
|
|
this.interactive = true;
|
|
this.on('added', e => {
|
|
if (this.visible) {
|
|
this.show();
|
|
}
|
|
});
|
|
|
|
// background
|
|
//-----------------
|
|
let background = new PIXI.Graphics();
|
|
this.background = background;
|
|
this.addChild(this.background);
|
|
|
|
if (this.opts.closeOnBackground) {
|
|
background.interactive = true;
|
|
background.on('pointerup', e => {
|
|
this.hide();
|
|
});
|
|
}
|
|
|
|
// popup
|
|
//-----------------
|
|
const popupOpts = Object.assign({}, this.opts, {
|
|
visible: true,
|
|
onHidden: () => {
|
|
this.hide();
|
|
}
|
|
});
|
|
let popup = new InteractivePopup(popupOpts);
|
|
this.popup = popup;
|
|
this.addChild(popup);
|
|
popup.show();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the modal. Can be used after resizing.
|
|
*
|
|
* @return {Modal} A reference to the modal for chaining.
|
|
*/
|
|
layout() {
|
|
const width = this.opts.app.size.width;
|
|
const height = this.opts.app.size.height;
|
|
|
|
// background
|
|
//-----------------
|
|
this.background.clear();
|
|
this.background.beginFill(this.opts.backgroundFill, this.opts.backgroundFillAlpha);
|
|
this.background.drawRect(0, 0, width, height);
|
|
this.background.endFill();
|
|
|
|
// position
|
|
this.popup.x = width / 2 - this.popup.width / 2;
|
|
this.popup.y = height / 2 - this.popup.height / 2;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Shows the modal (sets his alpha values to 1).
|
|
*
|
|
* @return {Modal} A reference to the modal for chaining.
|
|
*/
|
|
show() {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 1,
|
|
onStart: () => (this.visible = true)
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the modal (sets his alpha values to 0).
|
|
*
|
|
* @return {Modal} A reference to the modal for chaining.
|
|
*/
|
|
hide() {
|
|
TweenLite.to(this, this.theme.fast, {
|
|
alpha: 0,
|
|
onComplete: () => (this.visible = false)
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Sets or gets the header. The getter always returns a PIXI.Text object. The setter can receive
|
|
* a string or a PIXI.Text object.
|
|
*
|
|
* @member {string|PIXI.Text}
|
|
*/
|
|
get header() {
|
|
return this.popup.header
|
|
}
|
|
set header(value) {
|
|
this.opts.header = value;
|
|
this.background.destroy();
|
|
this.popup.destroy();
|
|
this.setup().layout();
|
|
}
|
|
|
|
/**
|
|
* Sets or gets the content. The getter always returns an PIXI.DisplayObject. The setter can receive
|
|
* a string or a PIXI.DisplayObject.
|
|
*
|
|
* @member {string|PIXI.DisplayObject}
|
|
*/
|
|
get content() {
|
|
return this.popup.content
|
|
}
|
|
set content(value) {
|
|
this.opts.content = value;
|
|
this.background.destroy();
|
|
this.popup.destroy();
|
|
this.setup().layout();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a Message. A message pops up and disappears after a specific amount of time.
|
|
*
|
|
* @example
|
|
* // Create the PixiJS App
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* // Create a button
|
|
* let button = new Button({
|
|
* label: 'Click me',
|
|
* action: e => {
|
|
* const message = new Message({
|
|
* app: app,
|
|
* header: 'Header',
|
|
* content: 'Text.'
|
|
* })
|
|
* app.scene.addChild(message)
|
|
* }
|
|
* })
|
|
*
|
|
* // Add the button to the scene
|
|
* app.scene.addChild(button)
|
|
*
|
|
* @class
|
|
* @extends InteractivePopup
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/message.html|DocTest}
|
|
*/
|
|
class Message extends InteractivePopup {
|
|
/**
|
|
* Creates an instance of a Message.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the message.
|
|
* @param {PIXIApp} [opts.app=window.app] - The PIXIApp where this message belongs to.
|
|
* @param {boolean} [opts.closeButton=false] - Should a close button be displayed in the upper right corner?
|
|
* @param {number} [opts.minWidth=280] - The minimum width of the message box. Automatically expands with the content.
|
|
* @param {number} [opts.minHeight=100] - The minimum height of the message box. Automatically expands with the content.
|
|
* @param {number} [opts.margin=Theme.margin] - The outer spacing of the message box.
|
|
* @param {string} [opts.align=right] - The horizontal position of the message box relative to the app. Possible
|
|
* values are left, center, right.
|
|
* @param {string} [opts.verticalAlign=top] - The vertical position of the message box relative to the app. Possible
|
|
* values are top, middle, bottom.
|
|
* @param {number} [opts.duration=5] - The duration in seconds when the message box should disappear.
|
|
* @param {boolean} [opts.autoClose=true] - Should the message box be closed automatically?
|
|
* @param {number} [opts.closeDuration=Theme.fast] - The duration in seconds of the closing of the message box.
|
|
*/
|
|
constructor(opts = {}) {
|
|
const theme = Theme.fromString(opts.theme);
|
|
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
app: window.app,
|
|
closeButton: false,
|
|
minWidth: 280,
|
|
minHeight: 100,
|
|
margin: theme.margin,
|
|
align: 'right', // left, center, right
|
|
verticalAlign: 'top', // top, middle, bottom
|
|
duration: 5,
|
|
autoClose: true,
|
|
closeDuration: theme.fast
|
|
},
|
|
opts
|
|
);
|
|
|
|
super(opts);
|
|
}
|
|
|
|
/**
|
|
* Relayouts the position of the message box.
|
|
*
|
|
* @return {Message} Returns the message box for chaining.
|
|
*/
|
|
layout() {
|
|
super.layout();
|
|
|
|
// horizontal
|
|
switch (this.opts.align) {
|
|
case 'left':
|
|
this.x = this.opts.margin;
|
|
break
|
|
case 'center':
|
|
this.x = this.opts.app.size.width / 2 - this.width / 2;
|
|
break
|
|
case 'right':
|
|
this.x = this.opts.app.size.width - this.opts.margin - this.width;
|
|
break
|
|
}
|
|
|
|
// vertical
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
this.y = this.opts.margin;
|
|
break
|
|
case 'middle':
|
|
this.y = this.opts.app.size.height / 2 - this.height / 2;
|
|
break
|
|
case 'bottom':
|
|
this.y = this.opts.app.size.height - this.opts.margin - this.height;
|
|
break
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows the message box.
|
|
*
|
|
* @private
|
|
*/
|
|
show() {
|
|
super.show();
|
|
|
|
if (this.opts.autoClose) {
|
|
window.setTimeout(() => {
|
|
this.hide();
|
|
}, this.opts.duration * 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* global apollo, subscriptions, gql */
|
|
|
|
/**
|
|
* A special InteractionManager for fullscreen apps, which may
|
|
* go beyond the limits of WebGL drawing buffers. On Safari and Chrome
|
|
* the drawing buffers are limited to 4096 in width (Safari) or 4096x4096
|
|
* in total buffer size (Chrome). The original InteractionManager.mapPositionToPoint
|
|
* does not work with these extreme sizes which mainly occur if large
|
|
* retina displays (>= 4K) are used with devicePixelRatio > 1.
|
|
*
|
|
* @private
|
|
* @class
|
|
* @extends PIXI.interaction.InteractionManager
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.interaction.InteractionManager.html|PIXI.interaction.InteractionManager}
|
|
* @see {@link https://stackoverflow.com/questions/29710696/webgl-drawing-buffer-size-does-not-equal-canvas-size}
|
|
*/
|
|
class FullscreenInteractionManager extends PIXI.interaction.InteractionManager {
|
|
mapPositionToPoint(point, x, y) {
|
|
let resolution = this.renderer.resolution;
|
|
let extendWidth = 1.0;
|
|
let extendHeight = 1.0;
|
|
let dy = 0;
|
|
let canvas = this.renderer.view;
|
|
let context = canvas.getContext('webgl');
|
|
if (context.drawingBufferWidth < canvas.width || context.drawingBufferHeight < canvas.height) {
|
|
extendWidth = context.drawingBufferWidth / canvas.width;
|
|
extendHeight = context.drawingBufferHeight / canvas.height;
|
|
//dx = wantedWidth - context.drawingBufferWidth
|
|
dy = (canvas.height - context.drawingBufferHeight) / resolution;
|
|
}
|
|
x *= extendWidth;
|
|
y *= extendHeight;
|
|
|
|
super.mapPositionToPoint(point, x, y + dy);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The class PixiApp extends the class PIXI.Application
|
|
* by several functions and makes meaningful pre-assumptions.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 450,
|
|
* height: 150,
|
|
* fpsLogging: true,
|
|
* theme: 'light',
|
|
* transparent: false
|
|
* }).setup().run()
|
|
*
|
|
* @class
|
|
* @extends PIXI.Application
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Application.html|PIXI.Application}
|
|
*/
|
|
class PIXIApp extends PIXI.Application {
|
|
/**
|
|
* Creates an instance of a PixiApp.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts={}] - An options object. The following options can be set:
|
|
* @param {number} [opts.width] - The width of the renderer. If no set, the application will run in fullscreen.
|
|
* @param {number} [opts.height] - The height of the renderer. If no set, the application will run in fullscreen.
|
|
* @param {HTMLElement} [opts.view] - The canvas HTML element. If not set, a render-element is added inside the body.
|
|
* @param {boolean} [opts.transparent=true] - Should the render view be transparent?
|
|
* @param {boolean} [opts.antialias=true] - Sets antialias (only applicable in chrome at the moment).
|
|
* @param {number} [opts.resolution=window.devicePixelRatio | 1] - The resolution / device pixel ratio of the renderer, retina would be 2.
|
|
* @param {boolean} [opts.autoResize=true] - Should the canvas-element be resized automatically if the resolution was set?
|
|
* @param {number} [opts.backgroundColor=0x282828] - The color of the background.
|
|
* @param {string|Theme} [opts.theme=dark] - The name of the theme (dark, light, red) or a Theme object to use for styling.
|
|
* @param {boolean} [opts.fpsLogging=false] - If set to true, the current frames per second are displayed in the upper left corner.
|
|
* @param {object} [opts.progress={}] - Can be used to add options to the progress bar. See class Progress for more informations.
|
|
* @param {boolean} [opts.forceCanvas=false] - Prevents selection of WebGL renderer, even if such is present.
|
|
* @param {boolean} [opts.roundPixels=true] - Align PIXI.DisplayObject coordinates to screen resolution.
|
|
* @param {boolean} [opts.monkeyPatchMapping=true] - Monkey patch for canvas fullscreen support on large displays.
|
|
* @param {boolean} [opts.adaptive=true] - Adds Graphics adaptive calculation of quadratic curve and arc subdivision.
|
|
*/
|
|
constructor({
|
|
width = null,
|
|
height = null,
|
|
view = null,
|
|
transparent = true,
|
|
backgroundColor = 0x282828,
|
|
theme = 'dark',
|
|
antialias = true,
|
|
resolution = window.devicePixelRatio || 1,
|
|
autoResize = true,
|
|
fpsLogging = false,
|
|
progress = {},
|
|
forceCanvas = false,
|
|
roundPixels = true,
|
|
monkeyPatchMapping = true,
|
|
adaptive = true,
|
|
graphql = false
|
|
}) {
|
|
const fullScreen = !width || !height;
|
|
|
|
if (fullScreen) {
|
|
width = window.innerWidth;
|
|
height = window.innerHeight;
|
|
}
|
|
|
|
super({
|
|
view,
|
|
width,
|
|
height,
|
|
transparent,
|
|
antialias,
|
|
resolution,
|
|
autoResize,
|
|
backgroundColor,
|
|
forceCanvas
|
|
});
|
|
|
|
this.width = width;
|
|
this.height = height;
|
|
this.theme = Theme.fromString(theme);
|
|
this.fpsLogging = fpsLogging;
|
|
this.progressOpts = progress;
|
|
this.fullScreen = fullScreen;
|
|
this.orient = null;
|
|
this.originalMapPositionToPoint = null;
|
|
this.monkeyPatchMapping = monkeyPatchMapping;
|
|
if (parseInt(PIXI.VERSION) >= 5) {
|
|
PIXI.settings.ROUND_PIXELS = roundPixels;
|
|
PIXI.GRAPHICS_CURVES.adaptive = adaptive;
|
|
} else {
|
|
PIXI.Graphics.CURVES.adaptive = adaptive;
|
|
}
|
|
this.graphql = graphql;
|
|
if (fullScreen || autoResize) {
|
|
console.log('App is in fullScreen mode or autoResize mode');
|
|
const resizeDebounced = debounce(event => this.resize(event), 50);
|
|
window.addEventListener('resize', resizeDebounced);
|
|
document.body.addEventListener('orientationchange', this.checkOrientation.bind(this));
|
|
}
|
|
if (monkeyPatchMapping) {
|
|
console.log('Using monkey patched coordinate mapping');
|
|
// Pluggin the specializtion does not work. Monkey patching does
|
|
// this.renderer.plugins.interaction = new FullscreenInteractionManager(this.renderer)
|
|
this.monkeyPatchPixiMapping();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extra setup method to construct complex scenes, etc...
|
|
* Overwrite this method if you need additonal views and components.
|
|
*
|
|
* @return {PIXIApp} A reference to the PIXIApp for chaining.
|
|
*/
|
|
setup() {
|
|
this.scene = this.sceneFactory();
|
|
this.stage.addChild(this.scene);
|
|
|
|
// fpsLogging
|
|
if (this.fpsLogging) {
|
|
this.addFpsDisplay();
|
|
}
|
|
|
|
// GraphQL
|
|
if (this.graphql && typeof apollo !== 'undefined') {
|
|
const networkInterface = apollo.createNetworkInterface({
|
|
uri: '/graphql'
|
|
});
|
|
|
|
const wsClient = new subscriptions.SubscriptionClient(`wss://${location.hostname}/subscriptions`, {
|
|
reconnect: true,
|
|
connectionParams: {}
|
|
});
|
|
|
|
const networkInterfaceWithSubscriptions = subscriptions.addGraphQLSubscriptions(networkInterface, wsClient);
|
|
|
|
this.apolloClient = new apollo.ApolloClient({
|
|
networkInterface: networkInterfaceWithSubscriptions
|
|
});
|
|
}
|
|
|
|
// progress
|
|
this._progress = new Progress(
|
|
Object.assign({ theme: this.theme }, this.progressOpts, {
|
|
app: this
|
|
})
|
|
);
|
|
this._progress.visible = false;
|
|
this.stage.addChild(this._progress);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Tests whether the width is larger than the height of the application.
|
|
*
|
|
* @return {boolean} Returns true if app is in landscape mode.
|
|
*/
|
|
orientation() {
|
|
return this.width > this.height
|
|
}
|
|
|
|
/**
|
|
* Checks orientation and adapts view size if necessary. Implements a
|
|
* handler for the orientationchange event.
|
|
*
|
|
* @param {event=} - orientationchange event
|
|
*/
|
|
checkOrientation(event) {
|
|
var value = this.orientation();
|
|
if (value != this.orient) {
|
|
setTimeout(
|
|
100,
|
|
function() {
|
|
this.orientationChanged(true);
|
|
}.bind(this)
|
|
);
|
|
this.orient = value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called if checkOrientation detects an orientation change event.
|
|
*
|
|
* @param {boolean=} [force=false] - Called if checkOrientation detects an orientation change event.
|
|
*/
|
|
orientationChanged(force = false) {
|
|
if (this.expandRenderer() || force) {
|
|
this.layout();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called after a resize. Empty method but can be overwritten to
|
|
* adapt their layout to the new app size.
|
|
*
|
|
* @param {number} [width] - The width of the app.
|
|
* @param {number} [height] - The height of the app.
|
|
*/
|
|
layout(width, height) {}
|
|
|
|
/**
|
|
* Draws the display tree of the app. Typically this can be delegated
|
|
* to the layout method.
|
|
*
|
|
*/
|
|
draw() {
|
|
this.layout(this.width, this.height);
|
|
}
|
|
|
|
/*
|
|
* Run the application. Override this method with everything
|
|
* that is needed to maintain your App, e.g. setup calls, main loops, etc.
|
|
*
|
|
*/
|
|
run() {
|
|
return this
|
|
}
|
|
|
|
/*
|
|
* Overwrite this factory method if your application needs a special
|
|
* scene object.
|
|
*
|
|
* @returns {PIXI.Container} - A new PIXI Container for use as a scene.
|
|
*/
|
|
sceneFactory() {
|
|
return new PIXI.Container()
|
|
}
|
|
|
|
/**
|
|
* Adds the display of the frames per second to the renderer in the upper left corner.
|
|
*
|
|
* @return {PIXIApp} - Returns the PIXIApp for chaining.
|
|
*/
|
|
addFpsDisplay() {
|
|
const fpsDisplay = new FpsDisplay(this);
|
|
this.stage.addChild(fpsDisplay);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Returns the size of the renderer as an object with the keys width and height.
|
|
*
|
|
* @readonly
|
|
* @member {object}
|
|
*/
|
|
get size() {
|
|
return { width: this.width, height: this.height }
|
|
}
|
|
|
|
/**
|
|
* Returns the center of the renderer as an object with the keys x and y.
|
|
*
|
|
* @readonly
|
|
* @member {object}
|
|
*/
|
|
get center() {
|
|
return { x: this.width / 2, y: this.height / 2 }
|
|
}
|
|
|
|
/**
|
|
* Resizes the renderer to fit into the window or given width and height.
|
|
*
|
|
* @param {object} [event] - The event.
|
|
* @param {object=} [opts={}] - The event.
|
|
* @param {number} [opts.width=window.innerWidth] - The width of the app to resize to.
|
|
* @param {number} [opts.height=window.innerHeight] - The height of the app to resize to.
|
|
* @return {PIXIApp} - Returns the PIXIApp for chaining.
|
|
*/
|
|
resize(event, { width = window.innerWidth, height = window.innerHeight } = {}) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.expandRenderer();
|
|
this.layout(width, height);
|
|
//console.log("App.resize", width, height, window.innerWidth, window.innerHeight )
|
|
// if (this.scene) {
|
|
// console.log("gl.drawingBufferWidth", this.renderer.view.getContext('webgl').drawingBufferWidth)
|
|
// console.log("scene", this.scene.scale, this.renderer, this.renderer.autoResize, this.renderer.resolution)
|
|
// }
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* @todo Write the documentation.
|
|
*
|
|
* @private
|
|
*/
|
|
monkeyPatchPixiMapping() {
|
|
if (this.originalMapPositionToPoint === null) {
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
this.originalMapPositionToPoint = interactionManager.mapPositionToPoint;
|
|
interactionManager.mapPositionToPoint = (point, x, y) => {
|
|
return this.fixedMapPositionToPoint(point, x, y)
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In some browsers the canvas is distorted if the screen resolution and
|
|
* overall size of the canvas exceeds the internal limits (e.g. 4096 x 4096 pixels).
|
|
* To compensate these distortions we need to fix the mapping to the actual
|
|
* drawing buffer coordinates.
|
|
* @private
|
|
* @param {any} local
|
|
* @param {number} x
|
|
* @param {number} y
|
|
* @return {} interactionManager.mapPositionToPoint
|
|
*/
|
|
fixedMapPositionToPoint(local, x, y) {
|
|
let resolution = this.renderer.resolution;
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
let extendWidth = 1.0;
|
|
let extendHeight = 1.0;
|
|
let dy = 0;
|
|
let canvas = this.renderer.view;
|
|
let context = canvas.getContext('webgl');
|
|
|
|
if (
|
|
context !== null &&
|
|
(context.drawingBufferWidth < canvas.width || context.drawingBufferHeight < canvas.height)
|
|
) {
|
|
extendWidth = context.drawingBufferWidth / canvas.width;
|
|
extendHeight = context.drawingBufferHeight / canvas.height;
|
|
//dx = wantedWidth - context.drawingBufferWidth
|
|
dy = (canvas.height - context.drawingBufferHeight) / resolution;
|
|
}
|
|
x *= extendWidth;
|
|
y *= extendHeight;
|
|
return this.originalMapPositionToPoint.call(interactionManager, local, x, y + dy)
|
|
}
|
|
|
|
/**
|
|
* Expand the renderer step-wise on resize.
|
|
*
|
|
* @param {number} [expand] - The amount of additional space for the renderer [px].
|
|
* @return {boolean} true if the renderer was resized.
|
|
*/
|
|
expandRenderer(expand = 256) {
|
|
let renderer = this.renderer;
|
|
let resolution = this.renderer.resolution;
|
|
let ww = this.width;
|
|
let hh = this.height;
|
|
let sw = this.screen.width;
|
|
let sh = this.screen.height;
|
|
if (ww > sw || hh > sh) {
|
|
//console.log('App.expandRenderer')
|
|
renderer.resize(ww + expand, hh + expand);
|
|
return true
|
|
}
|
|
|
|
renderer.resize(ww, hh);
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Set the loading progress of the application. If called for the first time, display the progress bar.
|
|
*
|
|
* @param {number} [value] - Should be a value between 0 and 100. If 100, the progress bar will disappear.
|
|
* @return {PIXIApp|Progress} The PixiApp object for chaining or the Progress object when the method was
|
|
* called without a parameter.
|
|
*/
|
|
progress(value) {
|
|
if (typeof value === 'undefined') {
|
|
return this._progress
|
|
}
|
|
|
|
this._progress.visible = true;
|
|
this._progress.progress = value;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Opens a new Modal object binded to this app.
|
|
*
|
|
* @param {object} [opts] - An options object for the Modal object.
|
|
* @return {Modal} Returns the Modal object.
|
|
*/
|
|
modal(opts = {}) {
|
|
let modal = new Modal(Object.assign({ theme: this.theme }, opts, { app: this }));
|
|
this.scene.addChild(modal);
|
|
|
|
return modal
|
|
}
|
|
|
|
/**
|
|
* Opens a new Message object binded to this app.
|
|
*
|
|
* @param {object} [opts] - An options object for the Message object.
|
|
* @return {Message} Returns the Message object.
|
|
*/
|
|
message(opts = {}) {
|
|
let message = new Message(Object.assign({ theme: this.theme }, opts, { app: this }));
|
|
this.scene.addChild(message);
|
|
|
|
return message
|
|
}
|
|
|
|
/**
|
|
* Loads sprites, e.g. images into the PIXI TextureCache.
|
|
*
|
|
* @param {string|string[]} resources - A String or an Array of urls to the images to load.
|
|
* @param {function} [loaded] - A callback which is executed after all resources has been loaded.
|
|
* Receives one paramter, a Map of sprites where the key is the path of the image which was
|
|
* loaded and the value is the PIXI.Sprite object.
|
|
* @param {object} [opts] - An options object for more specific parameters.
|
|
* @param {boolean} [opts.resolutionDependent=true] - Should the sprites be loaded dependent of the
|
|
* renderer resolution?
|
|
* @param {boolean} [opts.progress=false] - Should a progress bar display the loading status?
|
|
* @return {PIXIApp} The PIXIApp object for chaining.
|
|
*/
|
|
loadSprites(resources, loaded = null, { resolutionDependent = true, progress = false } = {}) {
|
|
this.loadTextures(
|
|
resources,
|
|
textures => {
|
|
let sprites = new Map();
|
|
|
|
for (let [key, texture] of textures) {
|
|
sprites.set(key, new PIXI.Sprite(texture));
|
|
}
|
|
|
|
if (loaded) {
|
|
loaded.call(this, sprites);
|
|
}
|
|
},
|
|
{ resolutionDependent, progress }
|
|
);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Loads textures, e.g. images into the PIXI TextureCache.
|
|
*
|
|
* @param {string|string[]} resources - A String or an Array of urls to the images to load.
|
|
* @param {function} [loaded] - A callback which is executed after all resources has been loaded.
|
|
* Receives one paramter, a Map of textures where the key is the path of the image which was
|
|
* loaded and the value is the PIXI.Texture object.
|
|
* @param {object} [opts] - An options object for more specific parameters.
|
|
* @param {boolean} [opts.resolutionDependent=true] - Should the textures be loaded dependent of the
|
|
* renderer resolution?
|
|
* @param {boolean} [opts.progress=false] - Should a progress bar display the loading status?
|
|
* @return {PIXIApp} The PIXIApp object for chaining.
|
|
*/
|
|
loadTextures(resources, loaded = null, { resolutionDependent = true, progress = false } = {}) {
|
|
if (!Array.isArray(resources)) {
|
|
resources = [resources];
|
|
}
|
|
|
|
const loader = this.loader;
|
|
|
|
for (let resource of resources) {
|
|
if (!loader.resources[resource]) {
|
|
if (resolutionDependent) {
|
|
let resolution = Math.round(this.renderer.resolution);
|
|
switch (resolution) {
|
|
case 2:
|
|
loader.add(resource, resource.replace(/\.([^.]*)$/, '@2x.$1'));
|
|
break
|
|
case 3:
|
|
loader.add(resource, resource.replace(/\.([^.]*)$/, '@3x.$1'));
|
|
break
|
|
default:
|
|
loader.add(resource);
|
|
break
|
|
}
|
|
} else {
|
|
loader.add(resource);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (progress) {
|
|
loader.on('progress', e => {
|
|
this.progress(e.progress);
|
|
});
|
|
}
|
|
|
|
loader.load((loader, resources) => {
|
|
const textures = new Map();
|
|
|
|
for (let key in resources) {
|
|
textures.set(key, resources[key].texture);
|
|
}
|
|
|
|
if (loaded) {
|
|
loaded.call(this, textures);
|
|
}
|
|
});
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Queries the GraphQL endpoint.
|
|
*
|
|
* @param {string} [query] - The GraphQL query string.
|
|
* @param {object} [opts={}] - An options object. The following options can be set:
|
|
* http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query
|
|
* @return {Promise} Returns a Promise which is either resolved with the resulting data or
|
|
* rejected with an error.
|
|
*/
|
|
query(query, opts = {}) {
|
|
if (typeof query === 'string') {
|
|
opts = Object.assign({}, opts, { query });
|
|
} else {
|
|
opts = Object.assign({}, query);
|
|
}
|
|
|
|
opts.query = opts.query.trim();
|
|
|
|
if (!opts.query.startsWith('query')) {
|
|
if (opts.query.startsWith('{')) {
|
|
opts.query = `query ${opts.query}`;
|
|
} else {
|
|
opts.query = `query {${opts.query}}`;
|
|
}
|
|
}
|
|
|
|
opts.query = gql(opts.query);
|
|
|
|
return this.apolloClient.query(opts)
|
|
}
|
|
|
|
/**
|
|
* Mutate the GraphQL endpoint.
|
|
*
|
|
* @param {string} [mutation] - The GraphQL mutation string.
|
|
* @param {object} [opts={}] - An options object. The following options can be set:
|
|
* http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.mutate
|
|
* @return {Promise} Returns a Promise which is either resolved with the resulting data or
|
|
* rejected with an error.
|
|
*/
|
|
mutate(mutation, opts = {}) {
|
|
if (typeof mutation === 'string') {
|
|
opts = Object.assign({}, opts, { mutation });
|
|
} else {
|
|
opts = Object.assign({}, mutation);
|
|
}
|
|
|
|
opts.mutation = opts.mutation.trim();
|
|
|
|
if (!opts.mutation.startsWith('mutation')) {
|
|
if (opts.mutation.startsWith('{')) {
|
|
opts.mutation = `mutation ${opts.mutation}`;
|
|
} else {
|
|
opts.mutation = `mutation {${opts.mutation}}`;
|
|
}
|
|
}
|
|
|
|
opts.mutation = gql(opts.mutation);
|
|
|
|
return this.apolloClient.mutate(opts)
|
|
}
|
|
|
|
/**
|
|
* Subscribe the GraphQL endpoint.
|
|
*
|
|
* @param {string} [subscription] - The GraphQL subscription.
|
|
* @param {object} [opts={}] - An options object. The following options can be set:
|
|
* http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query
|
|
* @return {Promise} Returns a Promise which is either resolved with the resulting data or
|
|
* rejected with an error.
|
|
*/
|
|
subscribe(subscription, opts = {}) {
|
|
if (typeof subscription === 'string') {
|
|
opts = Object.assign({}, opts, { subscription });
|
|
} else {
|
|
opts = Object.assign({}, subscription);
|
|
}
|
|
|
|
opts.subscription = opts.subscription.trim();
|
|
|
|
if (!opts.subscription.startsWith('subscription')) {
|
|
if (opts.subscription.startsWith('{')) {
|
|
opts.subscription = `subscription ${opts.subscription}`;
|
|
} else {
|
|
opts.subscription = `subscription {${opts.subscription}}`;
|
|
}
|
|
}
|
|
|
|
opts.query = gql(opts.subscription);
|
|
|
|
delete opts.subscription;
|
|
|
|
return this.apolloClient.subscribe(opts)
|
|
}
|
|
|
|
/**
|
|
* Supports the page as a global coordinate system and converts browser page coordinates
|
|
* to local DisplayObject coordinates.
|
|
*
|
|
* @param {DisplayObject} displayObject - The PIXI displayObject.
|
|
* @param {number} x - The x coordinate.
|
|
* @param {number} y - The y coordinate.
|
|
*
|
|
* @return {PIXI.Point} Returns a PIXI.Point.
|
|
*/
|
|
|
|
convertPointFromPageToNode(displayObject, x, y) {
|
|
let resolution = this.renderer.resolution;
|
|
console.log('resolution', resolution);
|
|
let pixiGlobal = window.convertPointFromPageToNode(app.view, x, y);
|
|
pixiGlobal.x /= resolution;
|
|
pixiGlobal.y /= resolution;
|
|
return displayObject.toLocal(new PIXI.Point(pixiGlobal.x, pixiGlobal.y))
|
|
}
|
|
|
|
/**
|
|
* Supports the page as a global coordinate system and converts local DisplayObject coordinates
|
|
* to browser page coordinates.
|
|
*
|
|
* @param {DisplayObject} displayObject - The PIXI displayObject.
|
|
* @param {number} x - The x coordinate.
|
|
* @param {number} y - The y coordinate.
|
|
*
|
|
* @return {Point} Returns a DOM Point.
|
|
*/
|
|
|
|
convertPointFromNodeToPage(displayObject, x, y) {
|
|
let resolution = this.renderer.resolution;
|
|
let pixiGlobal = displayObject.toGlobal(new PIXI.Point(x, y));
|
|
pixiGlobal.x *= resolution;
|
|
pixiGlobal.y *= resolution;
|
|
// console.log("app.convertPointFromNodeToPage", pixiGlobal)
|
|
return window.convertPointFromNodeToPage(app.view, pixiGlobal.x, pixiGlobal.y)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The class fpsdisplay shows in the upper left corner
|
|
* of the renderer the current image refresh rate.
|
|
*
|
|
* @private
|
|
* @class
|
|
* @extends PIXI.Graphics
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
|
|
*/
|
|
class FpsDisplay extends PIXI.Graphics {
|
|
/**
|
|
* Creates an instance of a FpsDisplay.
|
|
*
|
|
* @constructor
|
|
* @param {PIXIApp} app - The PIXIApp where the frames per second should be displayed.
|
|
*/
|
|
constructor(app) {
|
|
super();
|
|
|
|
this.app = app;
|
|
|
|
this.lineStyle(3, 0x434f4f, 1)
|
|
.beginFill(0x434f4f, 0.6)
|
|
.drawRoundedRect(0, 0, 68, 32, 5)
|
|
.endFill()
|
|
.position.set(20, 20);
|
|
|
|
this.text = new PIXI.Text(
|
|
this.fps,
|
|
new PIXI.TextStyle({
|
|
fontFamily: 'Arial',
|
|
fontSize: 14,
|
|
fontWeight: 'bold',
|
|
fill: '#f6f6f6',
|
|
stroke: '#434f4f',
|
|
strokeThickness: 3
|
|
})
|
|
);
|
|
this.text.position.set(6, 6);
|
|
|
|
this.addChild(this.text);
|
|
|
|
this.refreshFps();
|
|
|
|
window.setInterval(this.refreshFps.bind(this), 1000);
|
|
}
|
|
|
|
/**
|
|
* Refreshes fps numer.
|
|
*
|
|
* @return {PIXIApp} Returns the PIXIApp object for chaining.
|
|
*/
|
|
refreshFps() {
|
|
this.text.text = `${this.app.ticker.FPS.toFixed(1)} fps`;
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A Gaussian blur filter. With this filter, you can blur an area of a PIXI.DisplayObject. This cannot
|
|
* be done with the PIXI.filters.BlurFilter (when you use the PIXI.filters.BlurFilter with
|
|
* an filter area, all pixels outside of the area are not displayed). Attention: The area of
|
|
* the filter is always in global scope, NOT relative to the PIXI.DisplayObject the filter
|
|
* is assigned to!
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 480,
|
|
* height: 270,
|
|
* transparent: false
|
|
* }).setup().run()
|
|
*
|
|
* // Add a video sprite
|
|
* const sprite = new PIXI.Sprite(PIXI.Texture.from("assets/blurfilter.mp4"))
|
|
* sprite.width = app.size.width
|
|
* sprite.height = app.size.height
|
|
* app.scene.addChild(sprite)
|
|
*
|
|
* // Create the filter and assign it to the scene
|
|
* const blurFilter = new BlurFilter(new PIXI.Rectangle(20, 20, 80, 60))
|
|
* app.scene.filters = [blurFilter]
|
|
*
|
|
* @class
|
|
* @extends PIXI.Filter
|
|
* @param {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject} shape The area where the blur effect should be applied to. Relative to the
|
|
* canvas, NOT relative to the PIXI.DisplayObject where the blur effect is assigned to!
|
|
* @param {number} [blur=50] The strength of the blur.
|
|
*/
|
|
class BlurFilter extends PIXI.Filter {
|
|
constructor(shape, blur = 50) {
|
|
super();
|
|
|
|
const normalized = this.normalize(shape);
|
|
|
|
this.tiltShiftXFilter = new TiltShiftXFilter(normalized, blur);
|
|
this.tiltShiftYFilter = new TiltShiftYFilter(normalized, blur);
|
|
}
|
|
|
|
apply(filterManager, input, output) {
|
|
let renderTarget = filterManager.getFilterTexture();
|
|
this.tiltShiftXFilter.apply(filterManager, input, renderTarget);
|
|
this.tiltShiftYFilter.apply(filterManager, renderTarget, output);
|
|
filterManager.returnFilterTexture(renderTarget);
|
|
|
|
// let renderTarget = filterManager.getRenderTarget(true)
|
|
// this.tiltShiftXFilter.apply(filterManager, input, renderTarget)
|
|
// this.tiltShiftYFilter.apply(filterManager, renderTarget, output)
|
|
// filterManager.returnRenderTarget(renderTarget)
|
|
}
|
|
|
|
/**
|
|
* The strength of the blur.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get blur() {
|
|
return this.tiltShiftXFilter.blur
|
|
}
|
|
set blur(value) {
|
|
this.tiltShiftXFilter.blur = this.tiltShiftYFilter.blur = value;
|
|
}
|
|
|
|
/**
|
|
* The blur shape.
|
|
*
|
|
* @member {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject}
|
|
*/
|
|
get shape() {
|
|
return this.tiltShiftXFilter.shape
|
|
}
|
|
set shape(value) {
|
|
this.tiltShiftXFilter.shape = this.tiltShiftYFilter.shape = this.normalize(value);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject} value
|
|
* @returns {Object}
|
|
*/
|
|
normalize(value) {
|
|
let shape = null;
|
|
|
|
if (value instanceof PIXI.Circle) {
|
|
shape = { type: 'circle', x: value.x, y: value.y, r: value.radius };
|
|
} else if (value instanceof PIXI.Rectangle) {
|
|
shape = {
|
|
type: 'rectangle',
|
|
x: value.x,
|
|
y: value.y,
|
|
width: value.width,
|
|
height: value.height
|
|
};
|
|
} else {
|
|
const bounds = value.getBounds();
|
|
shape = {
|
|
type: 'rectangle',
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
};
|
|
}
|
|
|
|
return shape
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A TiltShiftAxisFilter.
|
|
*
|
|
* @class
|
|
* @extends PIXI.Filter
|
|
* @abstract
|
|
* @private
|
|
*/
|
|
class TiltShiftAxisFilter extends PIXI.Filter {
|
|
constructor(shape, blur) {
|
|
const vertex = `
|
|
attribute vec2 aVertexPosition;
|
|
attribute vec2 aTextureCoord;
|
|
|
|
uniform mat3 projectionMatrix;
|
|
|
|
varying vec2 vVertexPosition;
|
|
varying vec2 vTextureCoord;
|
|
|
|
void main(void) {
|
|
vVertexPosition = aVertexPosition;
|
|
vTextureCoord = aTextureCoord;
|
|
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
|
|
}
|
|
`;
|
|
|
|
const fragment = `
|
|
|
|
varying vec2 vVertexPosition;
|
|
varying vec2 vTextureCoord;
|
|
|
|
uniform sampler2D uSampler;
|
|
|
|
uniform int shape;
|
|
uniform vec4 rectangle;
|
|
uniform vec3 circle;
|
|
uniform float blur;
|
|
uniform vec2 delta;
|
|
uniform vec2 texSize;
|
|
|
|
float random(vec3 scale, float seed) {
|
|
return fract(sin(dot(gl_FragCoord.xyz + seed, scale)) * 43758.5453 + seed);
|
|
}
|
|
|
|
void main(void) {
|
|
bool inside = false;
|
|
|
|
if (shape == 1) {
|
|
inside = distance(vVertexPosition, circle.xy) <= circle.z;
|
|
} else if (shape == 2) {
|
|
inside = vVertexPosition.x >= rectangle.x && vVertexPosition.x <= rectangle.z && vVertexPosition.y >= rectangle.y && vVertexPosition.y <= rectangle.w;
|
|
}
|
|
|
|
if (inside) {
|
|
vec4 color = vec4(0.0);
|
|
float total = 0.0;
|
|
|
|
float offset = random(vec3(12.9898, 78.233, 151.7182), 0.0);
|
|
|
|
for (float t = -30.0; t <= 30.0; t++) {
|
|
float percent = (t + offset - 0.5) / 30.0;
|
|
float weight = 1.0 - abs(percent);
|
|
vec4 sample = texture2D(uSampler, vTextureCoord + delta / texSize * percent * blur);
|
|
sample.rgb *= sample.a;
|
|
color += sample * weight;
|
|
total += weight;
|
|
}
|
|
|
|
gl_FragColor = color / total;
|
|
gl_FragColor.rgb /= gl_FragColor.a + 0.00001;
|
|
} else {
|
|
gl_FragColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
|
|
}
|
|
}
|
|
`;
|
|
|
|
super(vertex, fragment);
|
|
|
|
if (shape.type === 'circle') {
|
|
this.uniforms.shape = 1;
|
|
this.uniforms.circle = [shape.x, shape.y, shape.r];
|
|
} else {
|
|
this.uniforms.shape = 2;
|
|
this.uniforms.rectangle = [shape.x, shape.y, shape.x + shape.width, shape.y + shape.height];
|
|
}
|
|
this.uniforms.blur = blur;
|
|
this.uniforms.delta = new PIXI.Point(0, 0);
|
|
this.uniforms.texSize = new PIXI.Point(480, 270);
|
|
|
|
this.updateDelta();
|
|
}
|
|
|
|
/**
|
|
* The strength of the blur.
|
|
*
|
|
* @member {number}
|
|
* @memberof PIXI.filters.TiltShiftAxisFilter#
|
|
*/
|
|
get blur() {
|
|
return this.uniforms.blur
|
|
}
|
|
set blur(value) {
|
|
this.uniforms.blur = value;
|
|
}
|
|
|
|
/**
|
|
* The blur shape.
|
|
*
|
|
* @member {PIXI.Rectangle}
|
|
* @memberof PIXI.filters.TiltShiftAxisFilter#
|
|
*/
|
|
get shape() {
|
|
if (this.uniforms.shape === 1) {
|
|
const circle = this.uniforms.circle;
|
|
return new PIXI.Circle(circle[0], circle[1], circle[2])
|
|
} else {
|
|
const rectangle = this.uniforms.rectangle;
|
|
return new PIXI.Rectangle(rectangle[0], rectangle[1], rectangle[2], rectangle[3])
|
|
}
|
|
}
|
|
set shape(value) {
|
|
if (value.type === 'circle') {
|
|
this.uniforms.shape = 1;
|
|
this.uniforms.circle = [value.x, value.y, value.r];
|
|
} else {
|
|
this.uniforms.shape = 2;
|
|
this.uniforms.rectangle = [value.x, value.y, value.x + value.width, value.y + value.height];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A TiltShiftXFilter.
|
|
*
|
|
* @class
|
|
* @extends PIXI.TiltShiftAxisFilter
|
|
* @private
|
|
*/
|
|
class TiltShiftXFilter extends TiltShiftAxisFilter {
|
|
/**
|
|
* Updates the filter delta values.
|
|
*/
|
|
updateDelta() {
|
|
this.uniforms.delta.x = 0.1;
|
|
this.uniforms.delta.y = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A TiltShiftYFilter.
|
|
*
|
|
* @class
|
|
* @extends PIXI.TiltShiftAxisFilter
|
|
* @private
|
|
*/
|
|
class TiltShiftYFilter extends TiltShiftAxisFilter {
|
|
/**
|
|
* Updates the filter delta values.
|
|
*/
|
|
updateDelta() {
|
|
this.uniforms.delta.x = 0;
|
|
this.uniforms.delta.y = 0.1;
|
|
}
|
|
}
|
|
|
|
// In order to test this interface implementation run jsc interface.js
|
|
|
|
class Interface {
|
|
// Abstract interface that should be extended in interface subclasses.
|
|
// By convention all interfaces should start with an upper 'I'
|
|
|
|
static implementationError(klass) {
|
|
let interfaceKeys = Reflect.ownKeys(this.prototype);
|
|
let classKeys = Reflect.ownKeys(klass.prototype);
|
|
for (let key of interfaceKeys) {
|
|
let interfaceDesc = this.prototype[key];
|
|
let classDesc = klass.prototype[key];
|
|
if (typeof classDesc == 'undefined') return 'Missing ' + key
|
|
}
|
|
return null
|
|
}
|
|
|
|
static implementedBy(klass) {
|
|
// In the first step only checks whether the methods of this
|
|
// interface are all implemented by the given class
|
|
let error = this.implementationError(klass);
|
|
return error == null
|
|
}
|
|
|
|
// TODO: Specify optional methods
|
|
// static optionalMethods() {
|
|
// return [this.onMouseWheel]
|
|
// }
|
|
}
|
|
|
|
/* eslint-disable no-undef */
|
|
/* eslint-disable no-console */
|
|
/* eslint-disable no-unused-vars */
|
|
let ipc = null;
|
|
let logMessages = new Set();
|
|
let logHandlers = {
|
|
log: console.log,
|
|
warn: console.warn,
|
|
error: console.error
|
|
};
|
|
|
|
try {
|
|
ipc = require('electron').ipcRenderer;
|
|
logHandlers.log = message => ipc.send('log', message);
|
|
logHandlers.warn = message => ipc.send('warn', message);
|
|
logHandlers.error = message => ipc.send('error', message);
|
|
} catch (e) {
|
|
console.log('Cannot use electron logging.');
|
|
}
|
|
|
|
/** Basic class for app specific logging requirements.
|
|
* Can be used to implement persistent logging in electron apps.
|
|
* Uses a logMessage cache to prevent error overflows. This is
|
|
* needed since errors may occur very frequently
|
|
* (e.g. display update loops at 60fps, programmatic loops, ...).
|
|
*
|
|
* The logging handlers can be overwritten by calling the static
|
|
* setup method.
|
|
*/
|
|
class Logging$1 {
|
|
/** Static log function.
|
|
* @param {*} message
|
|
*/
|
|
static log(message) {
|
|
logHandlers.log(message);
|
|
}
|
|
|
|
/**
|
|
* Static warn function.
|
|
* Emits each warning only once per session.
|
|
* @param {*} message
|
|
*/
|
|
static warn(message) {
|
|
if (!logMessages.has(message)) {
|
|
logMessages.add(message);
|
|
logHandlers.warn(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static error function.
|
|
* Emits each error message only once per session.
|
|
* @param {*} message
|
|
*/
|
|
static error(message) {
|
|
if (!logMessages.has(message)) {
|
|
logMessages.add(message);
|
|
logHandlers.error(message);
|
|
}
|
|
}
|
|
|
|
static setup({ log = console.log, warn = console.warn, error = console.error } = {}) {
|
|
logHandlers.log = log;
|
|
logHandlers.warn = warn;
|
|
logHandlers.error = error;
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/** Interaction patterns
|
|
|
|
See interaction.html for explanation
|
|
*/
|
|
|
|
class IInteractionTarget extends Interface {
|
|
capture(event) {
|
|
return typeof true
|
|
}
|
|
|
|
onStart(event, interaction) {}
|
|
onMove(event, interaction) {}
|
|
onEnd(event, interaction) {}
|
|
|
|
onMouseWheel(event) {}
|
|
}
|
|
|
|
class IInteractionMapperTarget extends Interface {
|
|
capture(event) {
|
|
return typeof true
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
return IInteractionTarget
|
|
}
|
|
}
|
|
|
|
class PointMap extends MapProxy {
|
|
// Collects touch points, mouse coordinates, etc. as key value pairs.
|
|
// Keys are pointer and touch ids, the special "mouse" key.
|
|
// Values are points, i.e. all objects with numeric x and y properties.
|
|
constructor(points = {}) {
|
|
super();
|
|
for (let key in points) {
|
|
this.set(key, points[key]);
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
let points = [];
|
|
for (let key of this.keys()) {
|
|
let value = this.get(key);
|
|
points.push(`${key}:{x:${value.x}, y:${value.y}}`);
|
|
}
|
|
let attrs = points.join(', ');
|
|
return `[PointMap ${attrs}]`
|
|
}
|
|
|
|
clone() {
|
|
let result = new PointMap();
|
|
for (let key of this.keys()) {
|
|
let value = this.get(key);
|
|
result.set(key, { x: value.x, y: value.y });
|
|
}
|
|
return result
|
|
}
|
|
|
|
keyOf(value) {
|
|
for (let key of this.keys()) {
|
|
let p = this.get(key);
|
|
if (p.x == value.x && p.y == value.y) {
|
|
return key
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
firstKey() {
|
|
for (let key of this.keys()) {
|
|
return key
|
|
}
|
|
return null
|
|
}
|
|
|
|
first() {
|
|
for (let key of this.keys()) {
|
|
return this.get(key)
|
|
}
|
|
return null
|
|
}
|
|
|
|
farthests() {
|
|
if (this.size == 0) {
|
|
return null
|
|
}
|
|
let pairs = [];
|
|
for (let key of this.keys()) {
|
|
let p = this.get(key);
|
|
p.key = key;
|
|
for (let k of this.keys()) {
|
|
let q = this.get(k);
|
|
q.key = k;
|
|
pairs.push([p, q]);
|
|
}
|
|
}
|
|
let sorted = pairs.sort((a, b) => {
|
|
return Points.distance(b[0], b[1]) - Points.distance(a[0], a[1])
|
|
});
|
|
return sorted[0]
|
|
}
|
|
|
|
mean() {
|
|
if (this.size == 0) {
|
|
return null
|
|
}
|
|
let x = 0.0,
|
|
y = 0.0;
|
|
for (let p of this.values()) {
|
|
x += p.x;
|
|
y += p.y;
|
|
}
|
|
return { x: x / this.size, y: y / this.size }
|
|
}
|
|
}
|
|
|
|
class InteractionDelta {
|
|
/**
|
|
*Creates an instance of InteractionDelta.
|
|
* @param {*} x
|
|
* @param {*} y
|
|
* @param {*} zoom
|
|
* @param {*} rotate
|
|
* @param {*} about
|
|
* @param {*} number - number of involved pointer
|
|
* @param {*} distance - distance of farthests touch points
|
|
* @memberof InteractionDelta
|
|
*/
|
|
constructor(x, y, zoom, rotate, about, number, distance) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.zoom = zoom;
|
|
this.rotate = rotate;
|
|
this.about = about;
|
|
this.number = number;
|
|
this.distance = distance;
|
|
}
|
|
|
|
toString() {
|
|
let values = [];
|
|
for (let key of Object.keys(this)) {
|
|
let value = this[key];
|
|
if (key == 'about') {
|
|
values.push(`${key}:{x:${value.x}, y:${value.y}}`);
|
|
} else {
|
|
values.push(`${key}:${value}`);
|
|
}
|
|
}
|
|
let attrs = values.join(', ');
|
|
return `[InteractionDelta ${attrs}]`
|
|
}
|
|
}
|
|
|
|
class InteractionPoints {
|
|
constructor(parent = null) {
|
|
this.parent = parent;
|
|
this.current = new PointMap();
|
|
this.previous = new PointMap();
|
|
this.start = new PointMap();
|
|
this.ended = new PointMap();
|
|
this.timestamps = new Map();
|
|
}
|
|
|
|
moved(key) {
|
|
let current = this.current.get(key);
|
|
let previous = this.previous.get(key);
|
|
return Points.subtract(current, previous)
|
|
}
|
|
|
|
move() {
|
|
let current = this.current.mean();
|
|
let previous = this.previous.mean();
|
|
return Points.subtract(current, previous)
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between previous and current angles. Corrects
|
|
* value that are larger than 45°
|
|
* @param {*} a
|
|
* @param {*} b
|
|
* @returns delta
|
|
*/
|
|
diffAngle(a, b) {
|
|
let alpha = Math.atan2(Math.sin(a - b), Math.cos(a - b));
|
|
if (Math.abs(alpha) > Math.PI / 4) {
|
|
alpha -= Math.PI;
|
|
}
|
|
return alpha
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between interaction points at t and t+1.
|
|
*
|
|
* @returns InteractionDelta
|
|
* @memberof InteractionPoints
|
|
*/
|
|
delta() {
|
|
let prev = [];
|
|
let curr = [];
|
|
let cm = { x: 0, y: 0 };
|
|
let pm = { x: 0, y: 0 };
|
|
let count = 0;
|
|
for (let key of this.current.keys()) {
|
|
if (this.previous.has(key)) {
|
|
let p = this.previous.get(key);
|
|
let c = this.current.get(key);
|
|
pm = Points.add(pm, p);
|
|
cm = Points.add(cm, c);
|
|
prev.push(p);
|
|
curr.push(c);
|
|
count += 1;
|
|
}
|
|
}
|
|
if (count > 0) {
|
|
pm = Points.multiplyScalar(pm, 1 / count);
|
|
cm = Points.multiplyScalar(cm, 1 / count);
|
|
let delta = Points.subtract(cm, pm);
|
|
let scale = 0;
|
|
let scaled = 0;
|
|
let alpha = 0;
|
|
let zoom = 1;
|
|
for (let i = 0; i < count; i++) {
|
|
let p = prev[i];
|
|
let c = curr[i];
|
|
let previousAngle = Points.angle(p, pm);
|
|
let currentAngle = Points.angle(c, cm);
|
|
let diff = this.diffAngle(currentAngle, previousAngle);
|
|
alpha += diff;
|
|
|
|
let distance1 = Points.distance(p, pm);
|
|
let distance2 = Points.distance(c, cm);
|
|
if (distance1 != 0 && distance2 != 0) {
|
|
scale += distance2 / distance1;
|
|
scaled += 1;
|
|
}
|
|
}
|
|
if (scaled > 0) {
|
|
zoom = scale / scaled;
|
|
}
|
|
alpha /= count;
|
|
|
|
let current = this.current.farthests();
|
|
|
|
let c1 = current[0];
|
|
let c2 = current[1];
|
|
let distance2 = Points.distance(c1, c2);
|
|
|
|
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, count, distance2)
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between interaction points at t and t+1.
|
|
*
|
|
* @returns InteractionDelta
|
|
* @memberof InteractionPoints
|
|
*/
|
|
deltaByTwoFarthestsPoints() {
|
|
let csize = this.current.size;
|
|
let psize = this.previous.size;
|
|
if (csize >= 2 && csize == psize) {
|
|
// Reduce to the two farthests points
|
|
let current = this.current.farthests();
|
|
|
|
let c1 = current[0];
|
|
let c2 = current[1];
|
|
|
|
let p1 = this.previous.get(c1.key);
|
|
let p2 = this.previous.get(c2.key);
|
|
|
|
let d1 = Points.subtract(c1, p1);
|
|
let d2 = Points.subtract(c2, p2);
|
|
let cm = Points.mean(c1, c2);
|
|
|
|
// Using the mean leads to jumps between time slices with 3 and 2 fingers
|
|
// We use the mean of deltas instead
|
|
let delta = Points.mean(d1, d2);
|
|
let zoom = 1.0;
|
|
let distance1 = Points.distance(p1, p2);
|
|
let distance2 = Points.distance(c1, c2);
|
|
if (distance1 != 0 && distance2 != 0) {
|
|
zoom = distance2 / distance1;
|
|
}
|
|
let currentAngle = Points.angle(c1, c2);
|
|
let previousAngle = Points.angle(p1, p2);
|
|
let alpha = this.diffAngle(currentAngle, previousAngle);
|
|
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, csize, distance2)
|
|
} else if (csize == 1 && psize == 1 && this.current.firstKey() == this.previous.firstKey()) {
|
|
// We need to ensure that the keys are the same, since single points with different keys
|
|
// can jump
|
|
let current = this.current.first();
|
|
let previous = this.previous.first();
|
|
let delta = Points.subtract(current, previous);
|
|
return new InteractionDelta(delta.x, delta.y, 1.0, 0.0, current, csize)
|
|
}
|
|
return null
|
|
}
|
|
|
|
started(key, point) {
|
|
this.current.set(key, point);
|
|
this.start.set(key, point);
|
|
this.previous.set(key, point);
|
|
this.timestamps.set(key, performance.now());
|
|
}
|
|
|
|
update(key, point) {
|
|
// Returns true iff the key is new
|
|
this.current.set(key, point);
|
|
if (!this.start.has(key)) {
|
|
this.start.set(key, point);
|
|
this.previous.set(key, point);
|
|
this.timestamps.set(key, performance.now());
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
updatePrevious() {
|
|
for (let key of this.current.keys()) {
|
|
this.previous.set(key, this.current.get(key));
|
|
}
|
|
}
|
|
|
|
stop(key, point) {
|
|
if (this.current.has(key)) {
|
|
this.current.delete(key);
|
|
this.previous.delete(key);
|
|
this.ended.set(key, point);
|
|
}
|
|
}
|
|
|
|
finish(key, point) {
|
|
this.current.delete(key);
|
|
this.previous.delete(key);
|
|
this.start.delete(key);
|
|
this.timestamps.delete(key);
|
|
this.ended.delete(key);
|
|
}
|
|
|
|
isFinished() {
|
|
return this.current.size == 0
|
|
}
|
|
|
|
isNoLongerTwoFinger() {
|
|
return this.previous.size > 1 && this.current.size < 2
|
|
}
|
|
|
|
isTap(key) {
|
|
return this.parent.isTap(key)
|
|
}
|
|
|
|
isDoubleTap(key) {
|
|
return this.parent.isDoubleTap(key)
|
|
}
|
|
|
|
isLongPress(key) {
|
|
return this.parent.isLongPress(key)
|
|
}
|
|
}
|
|
|
|
class Interaction extends InteractionPoints {
|
|
constructor(tapDistance = 10, tapDuration = 250.0, longPressTime = 500.0) {
|
|
super();
|
|
this.tapDistance = tapDistance;
|
|
this.tapCounts = new Map();
|
|
this.tapPositions = new Map();
|
|
this.tapTimestamps = new Map();
|
|
this.tapDuration = tapDuration;
|
|
this.longPressTime = longPressTime;
|
|
this.targets = new Map();
|
|
this.subInteractions = new Map(); // target:Object : InteractionPoints
|
|
}
|
|
|
|
stop(key, point) {
|
|
super.stop(key, point);
|
|
for (let points of this.subInteractions.values()) {
|
|
points.stop(key, point);
|
|
}
|
|
}
|
|
|
|
addTarget(key, target) {
|
|
this.targets.set(key, target);
|
|
this.subInteractions.set(target, new InteractionPoints(this));
|
|
}
|
|
|
|
removeTarget(key) {
|
|
let target = this.targets.get(key);
|
|
this.targets.delete(key);
|
|
// Only remove target if no keys are refering to the target
|
|
let remove = true;
|
|
for (let t of this.targets.values()) {
|
|
if (target === t) {
|
|
remove = false;
|
|
}
|
|
}
|
|
if (remove) {
|
|
this.subInteractions.delete(target);
|
|
}
|
|
}
|
|
|
|
finish(key, point) {
|
|
super.finish(key, point);
|
|
this.removeTarget(key);
|
|
}
|
|
|
|
mapInteraction(points, aspects, mappingFunc) {
|
|
// Map centrally registered points to target interactions
|
|
// Returns an array of [target, updated subInteraction] pairs
|
|
let result = new Map();
|
|
for (let key in points) {
|
|
if (this.targets.has(key)) {
|
|
let target = this.targets.get(key);
|
|
if (this.subInteractions.has(target)) {
|
|
let interaction = this.subInteractions.get(target);
|
|
for (let aspect of aspects) {
|
|
let pointMap = this[aspect];
|
|
let point = pointMap.get(key);
|
|
let mapped = mappingFunc(point, target);
|
|
interaction[aspect].set(key, mapped);
|
|
}
|
|
result.set(target, interaction);
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
registerTap(key, point) {
|
|
if (this.tapCounts.has(key)) {
|
|
let count = this.tapCounts.get(key);
|
|
this.tapCounts.set(key, count + 1);
|
|
} else {
|
|
this.tapCounts.set(key, 1);
|
|
}
|
|
this.tapPositions.set(key, point);
|
|
this.tapTimestamps.set(key, performance.now());
|
|
}
|
|
|
|
unregisterTap(key) {
|
|
this.tapCounts.delete(key);
|
|
this.tapPositions.delete(key);
|
|
this.tapTimestamps.delete(key);
|
|
}
|
|
|
|
isTap(key) {
|
|
let ended = this.ended.get(key);
|
|
let start = this.start.get(key);
|
|
if (start && ended && Points.distance(ended, start) < this.tapDistance) {
|
|
let t1 = this.timestamps.get(key);
|
|
let tookLong = performance.now() > t1 + this.longPressTime;
|
|
if (tookLong) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isDoubleTap(key) {
|
|
let ended = this.ended.get(key);
|
|
if (this.tapCounts.has(key) && this.tapCounts.get(key) > 2) {
|
|
this.unregisterTap(key);
|
|
}
|
|
if (this.tapPositions.has(key)) {
|
|
let pos = this.tapPositions.get(key);
|
|
if (Points.distance(ended, pos) > this.tapDistance) {
|
|
this.unregisterTap(key);
|
|
}
|
|
}
|
|
if (this.tapTimestamps.has(key) && performance.now() > this.tapTimestamps.get(key) + this.tapDuration) {
|
|
//console.log("tap too long")
|
|
this.unregisterTap(key);
|
|
}
|
|
let result = false;
|
|
if (this.isTap(key)) {
|
|
this.registerTap(key, ended);
|
|
result = this.tapCounts.get(key) == 2;
|
|
} else {
|
|
this.unregisterTap(key);
|
|
}
|
|
//console.log("isDoubleTap", this.tapCounts.get(key), result)
|
|
return result
|
|
}
|
|
|
|
isAnyTap() {
|
|
for (let key of this.ended.keys()) {
|
|
if (this.isTap(key)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isLongPress(key) {
|
|
let ended = this.ended.get(key);
|
|
let start = this.start.get(key);
|
|
if (start && ended && Points.distance(ended, start) < this.tapDistance) {
|
|
let t1 = this.timestamps.get(key);
|
|
let tookLong = performance.now() > t1 + this.longPressTime;
|
|
if (tookLong) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
isAnyLongPress() {
|
|
for (let key of this.ended.keys()) {
|
|
if (this.isLongPress(key)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isStylus(key) {
|
|
return key === 'stylus'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This class implements the main delegate functionality: All necessary event handlers are registered for the
|
|
* given element. Uses PointerEvents if available or TouchEvents on iOS. The fallback is on mouse events.
|
|
* Collects the events if the interaction target captures the start event (i.e. declares that
|
|
* the target wants the start event as well as all following move and end evcents.)
|
|
*
|
|
* @export
|
|
* @class InteractionDelegate
|
|
*/
|
|
class InteractionDelegate {
|
|
// Long press: http://stackoverflow.com/questions/1930895/how-long-is-the-event-onlongpress-in-the-android
|
|
// Stylus support: https://w3c.github.io/touch-events/
|
|
|
|
/**
|
|
* Creates an instance of InteractionDelegate.
|
|
* @param {any} element
|
|
* @param {any} target
|
|
* @param {any} [{ mouseWheelElement = null, useCapture = true, capturePointerEvents = true, debug = false }={}]
|
|
* @memberof InteractionDelegate
|
|
*/
|
|
constructor(
|
|
element,
|
|
target,
|
|
{
|
|
mouseWheelElement = null,
|
|
useCapture = true,
|
|
capturePointerEvents = true,
|
|
cancelOnWindowOut = true,
|
|
debug = false
|
|
} = {}
|
|
) {
|
|
this.debug = debug;
|
|
this.interaction = new Interaction();
|
|
this.element = element;
|
|
this.mouseWheelElement = mouseWheelElement || element;
|
|
this.target = target;
|
|
this.useCapture = useCapture;
|
|
this.capturePointerEvents = capturePointerEvents;
|
|
this.cancelOnWindowOut = cancelOnWindowOut;
|
|
this.setupInteraction();
|
|
}
|
|
|
|
setupInteraction() {
|
|
if (this.debug) {
|
|
let error = this.targetInterface.implementationError(this.target.constructor);
|
|
if (error != null) {
|
|
throw new Error('Expected IInteractionTarget: ' + error)
|
|
}
|
|
}
|
|
this.setupTouchInteraction();
|
|
this.setupMouseWheelInteraction();
|
|
}
|
|
|
|
get targetInterface() {
|
|
return IInteractionTarget
|
|
}
|
|
|
|
setupTouchInteraction() {
|
|
let element = this.element;
|
|
let useCapture = this.useCapture;
|
|
if (window.PointerEvent) {
|
|
if (this.debug) console.log('Pointer API' + window.PointerEvent);
|
|
element.addEventListener(
|
|
'pointerdown',
|
|
e => {
|
|
if (this.debug) console.log('pointerdown', e.pointerId);
|
|
if (this.capture(e)) {
|
|
if (this.capturePointerEvents) {
|
|
try {
|
|
element.setPointerCapture(e.pointerId);
|
|
} catch (e) {
|
|
console.warn('Cannot setPointerCapture');
|
|
}
|
|
}
|
|
this.onStart(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointermove',
|
|
e => {
|
|
if (this.debug) console.log('pointermove', e.pointerId, e.pointerType);
|
|
|
|
if (e.pointerType == 'touch' || (e.pointerType == 'mouse' && Events$1.isPointerDown(e))) {
|
|
// this.capture(e) &&
|
|
if (this.debug) console.log('pointermove captured', e.pointerId);
|
|
this.onMove(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointerup',
|
|
e => {
|
|
if (this.debug) console.log('pointerup', e.pointerId, e.pointerType);
|
|
this.onEnd(e);
|
|
if (this.capturePointerEvents) {
|
|
try {
|
|
element.releasePointerCapture(e.pointerId);
|
|
} catch (e) {
|
|
console.warn('Cannot release pointer');
|
|
}
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointercancel',
|
|
e => {
|
|
if (this.debug) console.log('pointercancel', e.pointerId, e.pointerType);
|
|
this.onEnd(e);
|
|
if (this.capturePointerEvents) element.releasePointerCapture(e.pointerId);
|
|
},
|
|
useCapture
|
|
);
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'pointerleave',
|
|
e => {
|
|
if (this.debug) console.log('pointerleave', e.pointerId, e.pointerType);
|
|
if (e.target == element) this.onEnd(e);
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'pointerout',
|
|
e => {
|
|
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType);
|
|
if (e.target == element) this.onEnd(e);
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
|
|
if (this.cancelOnWindowOut) {
|
|
window.addEventListener(
|
|
'pointerout',
|
|
e => {
|
|
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType, e.target);
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
} else if (window.TouchEvent) {
|
|
if (this.debug) console.log('Touch API');
|
|
element.addEventListener(
|
|
'touchstart',
|
|
e => {
|
|
if (this.debug) console.log('touchstart', this.touchPoints(e));
|
|
if (this.capture(e)) {
|
|
for (let touch of e.changedTouches) {
|
|
this.onStart(touch);
|
|
}
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchmove',
|
|
e => {
|
|
if (this.debug) console.log('touchmove', this.touchPoints(e), e);
|
|
for (let touch of e.changedTouches) {
|
|
this.onMove(touch);
|
|
}
|
|
for (let touch of e.targetTouches) {
|
|
this.onMove(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchend',
|
|
e => {
|
|
if (this.debug) console.log('touchend', this.touchPoints(e));
|
|
for (let touch of e.changedTouches) {
|
|
this.onEnd(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchcancel',
|
|
e => {
|
|
if (this.debug) console.log('touchcancel', e.targetTouches.length, e.changedTouches.length);
|
|
for (let touch of e.changedTouches) {
|
|
this.onEnd(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
} else {
|
|
if (this.debug) console.log('Mouse API');
|
|
|
|
element.addEventListener(
|
|
'mousedown',
|
|
e => {
|
|
if (this.debug) console.log('mousedown', e);
|
|
if (this.capture(e)) {
|
|
this.onStart(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'mousemove',
|
|
e => {
|
|
// Dow we only use move events if the mouse is down?
|
|
// HOver effects have to be implemented by other means
|
|
// && Events.isMouseDown(e))
|
|
|
|
if (Events$1.isMouseDown(e)) {
|
|
if (this.debug) console.log('mousemove', e);
|
|
this.onMove(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'mouseup',
|
|
e => {
|
|
if (this.debug) console.log('mouseup', e);
|
|
this.onEnd(e);
|
|
},
|
|
true
|
|
);
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'mouseout',
|
|
e => {
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
console.warn("Shouldn't happen: mouseout ends interaction");
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
if (this.cancelOnWindowOut) {
|
|
window.addEventListener(
|
|
'mouseout',
|
|
e => {
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
isDescendant(parent, child) {
|
|
if (parent == child) return true
|
|
let node = child.parentNode;
|
|
while (node != null) {
|
|
if (node == parent) {
|
|
return true
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false
|
|
}
|
|
|
|
touchPoints(event) {
|
|
let result = [];
|
|
for (let touch of event.changedTouches) {
|
|
result.push(this.extractPoint(touch));
|
|
}
|
|
return result
|
|
}
|
|
|
|
setupMouseWheelInteraction() {
|
|
this.mouseWheelElement.addEventListener('mousewheel', this.onMouseWheel.bind(this), true);
|
|
this.mouseWheelElement.addEventListener('DOMMouseScroll', this.onMouseWheel.bind(this), true);
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (this.capture(event) && this.target.onMouseWheel) {
|
|
this.target.onMouseWheel(event);
|
|
}
|
|
}
|
|
|
|
onStart(event) {
|
|
let extracted = this.extractPoint(event);
|
|
this.startInteraction(event, extracted);
|
|
this.target.onStart(event, this.interaction);
|
|
}
|
|
|
|
onMove(event) {
|
|
let extracted = this.extractPoint(event, 'all');
|
|
this.updateInteraction(event, extracted);
|
|
this.target.onMove(event, this.interaction);
|
|
this.interaction.updatePrevious();
|
|
}
|
|
|
|
onEnd(event) {
|
|
let extracted = this.extractPoint(event, 'changedTouches');
|
|
this.endInteraction(event, extracted);
|
|
this.target.onEnd(event, this.interaction);
|
|
this.finishInteraction(event, extracted);
|
|
}
|
|
|
|
/**
|
|
* Asks the target whether the event should be captured
|
|
*
|
|
* @param {any} event
|
|
* @returns {bool}
|
|
* @memberof InteractionDelegate
|
|
*/
|
|
capture(event) {
|
|
if (Events$1.isCaptured(event)) {
|
|
return false
|
|
}
|
|
let captured = this.target.capture(event);
|
|
return captured
|
|
}
|
|
|
|
getPosition(event) {
|
|
return { x: event.clientX, y: event.clientY }
|
|
}
|
|
|
|
extractPoint(event, touchEventKey = 'all') {
|
|
// 'targetTouches'
|
|
let result = {};
|
|
switch (event.constructor.name) {
|
|
case 'MouseEvent': {
|
|
let buttons = event.buttons || event.which;
|
|
if (buttons) result['mouse'] = this.getPosition(event);
|
|
break
|
|
}
|
|
case 'PointerEvent': {
|
|
result[event.pointerId.toString()] = this.getPosition(event);
|
|
break
|
|
}
|
|
case 'Touch': {
|
|
let id = event.touchType === 'stylus' ? 'stylus' : event.identifier.toString();
|
|
result[id] = this.getPosition(event);
|
|
break
|
|
}
|
|
// case 'TouchEvent':
|
|
// // Needs to be observed: Perhaps changedTouches are all we need. If so
|
|
// // we can remove the touchEventKey default parameter
|
|
// if (touchEventKey == 'all') {
|
|
// for(let t of event.targetTouches) {
|
|
// result[t.identifier.toString()] = this.getPosition(t)
|
|
// }
|
|
// for(let t of event.changedTouches) {
|
|
// result[t.identifier.toString()] = this.getPosition(t)
|
|
// }
|
|
// }
|
|
// else {
|
|
// for(let t of event.changedTouches) {
|
|
// result[t.identifier.toString()] = this.getPosition(t)
|
|
// }
|
|
// }
|
|
// break
|
|
default:
|
|
break
|
|
}
|
|
return result
|
|
}
|
|
|
|
interactionStarted(event, key, point) {
|
|
// Callback: can be overwritten
|
|
}
|
|
|
|
interactionEnded(event, key, point) {
|
|
// Callback: can be overwritten
|
|
}
|
|
|
|
interactionFinished(event, key, point) {}
|
|
|
|
startInteraction(event, extracted) {
|
|
for (let key in extracted) {
|
|
let point = extracted[key];
|
|
this.interaction.started(key, point);
|
|
this.interactionStarted(event, key, point);
|
|
}
|
|
}
|
|
|
|
updateInteraction(event, extracted) {
|
|
for (let key in extracted) {
|
|
let point = extracted[key];
|
|
let updated = this.interaction.update(key, point);
|
|
if (updated) {
|
|
console.warn("new pointer in updateInteraction shouldn't happen", key);
|
|
this.interactionStarted(event, key, point);
|
|
}
|
|
}
|
|
}
|
|
|
|
endInteraction(event, ended) {
|
|
for (let key in ended) {
|
|
let point = ended[key];
|
|
this.interaction.stop(key, point);
|
|
this.interactionEnded(event, key, point);
|
|
}
|
|
}
|
|
|
|
finishInteraction(event, ended) {
|
|
for (let key in ended) {
|
|
let point = ended[key];
|
|
this.interaction.finish(key, point);
|
|
this.interactionFinished(event, key, point);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* A special InteractionDelegate that maps events to specific parts of
|
|
* the interaction target. The InteractionTarget must implement a findTarget
|
|
* method that returns an object implementing the IInteractionTarget interface.
|
|
*
|
|
* If the InteractionTarget also implements a mapPositionToPoint method this
|
|
* is used to map the points to the local coordinate space of the the target.
|
|
*
|
|
* This makes it easier to lookup elements and relate events to local
|
|
* positions.
|
|
*
|
|
* @export
|
|
* @class InteractionMapper
|
|
* @extends {InteractionDelegate}
|
|
*/
|
|
class InteractionMapper$1 extends InteractionDelegate {
|
|
constructor(
|
|
element,
|
|
target,
|
|
{
|
|
tapDistance = 10,
|
|
longPressTime = 500.0,
|
|
useCapture = true,
|
|
capturePointerEvents = true,
|
|
mouseWheelElement = null,
|
|
logInteractionsAbove = 12
|
|
} = {}
|
|
) {
|
|
super(element, target, {
|
|
tapDistance,
|
|
useCapture,
|
|
capturePointerEvents,
|
|
longPressTime,
|
|
mouseWheelElement
|
|
});
|
|
this.logInteractionsAbove = logInteractionsAbove;
|
|
}
|
|
|
|
get targetInterface() {
|
|
return IInteractionMapperTarget
|
|
}
|
|
|
|
mapPositionToPoint(point, element = null) {
|
|
if (this.target.mapPositionToPoint) {
|
|
return this.target.mapPositionToPoint(point, element)
|
|
}
|
|
return point
|
|
}
|
|
|
|
interactionStarted(event, key, point) {
|
|
if (this.target.findTarget) {
|
|
let local = this.mapPositionToPoint(point);
|
|
let found = this.target.findTarget(event, local, point);
|
|
if (found != null) {
|
|
this.interaction.addTarget(key, found);
|
|
}
|
|
}
|
|
let size = this.interaction.current.size;
|
|
let limit = this.logInteractionsAbove;
|
|
if (size > limit) {
|
|
Logging$1.log(`Number of interactions ${size} exceeds ${limit}`);
|
|
}
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (this.capture(event)) {
|
|
if (this.target.findTarget) {
|
|
let point = this.getPosition(event);
|
|
let local = this.mapPositionToPoint(point);
|
|
let found = this.target.findTarget(event, local, point);
|
|
if (found != null && found.onMouseWheel) {
|
|
found.onMouseWheel(event);
|
|
return
|
|
}
|
|
}
|
|
if (this.target.onMouseWheel) {
|
|
this.target.onMouseWheel(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
onStart(event) {
|
|
let extracted = this.extractPoint(event);
|
|
this.startInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(
|
|
extracted,
|
|
['current', 'start'],
|
|
this.mapPositionToPoint.bind(this)
|
|
);
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onStart(event, interaction);
|
|
}
|
|
}
|
|
|
|
onMove(event) {
|
|
let extracted = this.extractPoint(event, 'all');
|
|
this.updateInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(
|
|
extracted,
|
|
['current', 'previous'],
|
|
this.mapPositionToPoint.bind(this)
|
|
);
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onMove(event, interaction);
|
|
interaction.updatePrevious();
|
|
}
|
|
this.interaction.updatePrevious();
|
|
}
|
|
|
|
onEnd(event) {
|
|
let extracted = this.extractPoint(event, 'changedTouches');
|
|
this.endInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(extracted, ['ended'], this.mapPositionToPoint.bind(this));
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onEnd(event, interaction);
|
|
}
|
|
this.finishInteraction(event, extracted);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @static
|
|
* @param {string|array} types - An event type, an array of event types or event types seperated by a space sign. The following
|
|
* events are possible:
|
|
* pan, panstart, panmove, panend, pancancel, panleft, panright, panup, pandown
|
|
* pinch, pinchstart, pinchmove, pinchend, pinchcancel, pinchin, pinchout
|
|
* press, pressup
|
|
* rotate, rotatestart, rotatemove, rotateend, rotatecancel
|
|
* swipe, swipeleft, swiperight, swipeup, swipedown
|
|
* tap
|
|
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
|
|
* @param {function} [cb] - The callback. A function which is executed after the event occurs. Receives the event object as the
|
|
* first paramter
|
|
* @param {object} [opts] - An options object. See the hammer documentation for more details.
|
|
*/
|
|
static on(types, elements, cb, opts = {}) {
|
|
opts = Object.assign({}, {}, opts);
|
|
|
|
if (typeof Hammer === 'undefined') {
|
|
console.error('Hammer.js not found!');
|
|
return this
|
|
}
|
|
|
|
if (typeof Hammer.__hammers === 'undefined') {
|
|
Hammer.__hammers = new Map();
|
|
}
|
|
|
|
// convert to array
|
|
types = Array.isArray(types) ? types : types.split(/\s/);
|
|
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
|
|
elements = Array.from(elements);
|
|
}
|
|
elements = Array.isArray(elements) ? elements : [elements];
|
|
|
|
for (let i = 0; i < types.length; i++) {
|
|
const type = types[i].toLowerCase();
|
|
|
|
// list of hammer events
|
|
const useHammer = /^(tap|doubletap|press|pan|swipe|pinch|rotate).*$/.test(type);
|
|
|
|
// if it is a hammer event
|
|
if (useHammer) {
|
|
for (let j = 0; j < elements.length; j++) {
|
|
// if(elements[j].tagName == "svg") return false;
|
|
|
|
let hammer = new Hammer(elements[j], opts);
|
|
|
|
if (window.propagating !== 'undefined') {
|
|
hammer = propagating(hammer);
|
|
}
|
|
|
|
// recognizers
|
|
if (type.startsWith('pan')) {
|
|
hammer.get('pan').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
|
|
} else if (type.startsWith('pinch')) {
|
|
hammer.get('pinch').set(Object.assign({ enable: true }, opts));
|
|
} else if (type.startsWith('press')) {
|
|
hammer.get('press').set(opts);
|
|
} else if (type.startsWith('rotate')) {
|
|
hammer.get('rotate').set(Object.assign({ enable: true }, opts));
|
|
} else if (type.startsWith('swipe')) {
|
|
hammer.get('swipe').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
|
|
} else if (type.startsWith('tap')) {
|
|
hammer.get('tap').set(opts);
|
|
}
|
|
|
|
hammer.on(type, event => {
|
|
cb(event);
|
|
});
|
|
|
|
if (Hammer.__hammers.has(elements[j])) {
|
|
const elementHammers = Hammer.__hammers.get(elements[j]);
|
|
elementHammers.push(hammer);
|
|
Hammer.__hammers.set(elements[j], elementHammers);
|
|
} else {
|
|
Hammer.__hammers.set(elements[j], [hammer]);
|
|
}
|
|
}
|
|
} else {
|
|
for (let j = 0; j < elements.length; j++) {
|
|
Hammer.on(elements[j], type, event => {
|
|
cb(event);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @static
|
|
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
|
|
*/
|
|
static off(elements) {
|
|
if (typeof Hammer === 'undefined') {
|
|
console.error('Hammer.js not found!');
|
|
return this
|
|
}
|
|
|
|
// convert to array
|
|
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
|
|
elements = Array.from(elements);
|
|
}
|
|
elements = Array.isArray(elements) ? elements : [elements];
|
|
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
|
|
if (Hammer.__hammers.has(element)) {
|
|
const elementHammers = Hammer.__hammers.get(element);
|
|
elementHammers.forEach(it => it.destroy());
|
|
Hammer.__hammers.delete(element);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
window.InteractionMapper = InteractionMapper$1;
|
|
|
|
/** Report capabilities with guaranteed values.
|
|
*/
|
|
class Capabilities {
|
|
/** Returns the browser userAgent.
|
|
@return {string}
|
|
*/
|
|
static get userAgent() {
|
|
return navigator.userAgent || 'Unknown Agent'
|
|
}
|
|
|
|
/** Tests whether the app is running on a mobile device.
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isMobile() {
|
|
return /Mobi/.test(navigator.userAgent)
|
|
}
|
|
|
|
/** Tests whether the app is running on a iOS device.
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isIOS() {
|
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
|
|
}
|
|
|
|
/** Tests whether the app is running in a Safari environment.
|
|
See https://stackoverflow.com/questions/7944460/detect-safari-browser
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isSafari() {
|
|
return (
|
|
navigator.vendor &&
|
|
navigator.vendor.indexOf('Apple') > -1 &&
|
|
navigator.userAgent &&
|
|
!navigator.userAgent.match('CriOS')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Distincts if the app is running inside electron or not.
|
|
*
|
|
* source: https://github.com/cheton/is-electron
|
|
*/
|
|
static get isElectron() {
|
|
// Renderer process
|
|
if (typeof window !== 'undefined' && typeof window.process === 'object' && window.process.type === 'renderer') {
|
|
return true
|
|
}
|
|
|
|
// Main process
|
|
if (typeof process !== 'undefined' && typeof process.versions === 'object' && !!process.versions.electron) {
|
|
return true
|
|
}
|
|
|
|
// Detect the user agent when the `nodeIntegration` option is set to true
|
|
if (
|
|
typeof navigator === 'object' &&
|
|
typeof navigator.userAgent === 'string' &&
|
|
navigator.userAgent.indexOf('Electron') >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/** Returns the display resolution. Necessary for retina displays.
|
|
@return {number}
|
|
*/
|
|
static get devicePixelRatio() {
|
|
return window.devicePixelRatio || 1
|
|
}
|
|
|
|
/** Returns true if the device is a multi-touch table. This method is currently not universal usable and not sure!
|
|
@return {boolean}
|
|
*/
|
|
static get isMultiTouchTable() {
|
|
return (
|
|
Capabilities.devicePixelRatio > 2 &&
|
|
Capabilities.isMobile === false &&
|
|
/Windows/i.test(Capabilities.userAgent)
|
|
)
|
|
}
|
|
|
|
/** Returns true if mouse events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsMouseEvents() {
|
|
return typeof window.MouseEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if touch events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsTouchEvents() {
|
|
return typeof window.TouchEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if pointer events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsPointerEvents() {
|
|
return typeof window.PointerEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if DOM templates are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsTemplate() {
|
|
return 'content' in document.createElement('template')
|
|
}
|
|
}
|
|
|
|
/** Basic tests for Capabilities.
|
|
*/
|
|
class CapabilitiesTests {
|
|
static testConfirm() {
|
|
let bool = confirm('Please confirm');
|
|
document.getElementById('demo').innerHTML = bool ? 'Confirmed' : 'Not confirmed';
|
|
}
|
|
|
|
static testPrompt() {
|
|
let person = prompt('Please enter your name', 'Harry Potter');
|
|
if (person != null) {
|
|
demo.innerHTML = 'Hello ' + person + '! How are you today?';
|
|
}
|
|
}
|
|
|
|
static testUserAgent() {
|
|
let agent = 'User-agent: ' + Capabilities.userAgent;
|
|
user_agent.innerHTML = agent;
|
|
}
|
|
|
|
static testDevicePixelRatio() {
|
|
let value = 'Device Pixel Ratio: ' + Capabilities.devicePixelRatio;
|
|
device_pixel_ratio.innerHTML = value;
|
|
}
|
|
|
|
static testMultiTouchTable() {
|
|
let value = 'Is the device a multi-touch table? ' + Capabilities.isMultiTouchTable;
|
|
multi_touch_table.innerHTML = value;
|
|
}
|
|
|
|
static testSupportedEvents() {
|
|
let events = [];
|
|
if (Capabilities.supportsMouseEvents()) {
|
|
events.push('MouseEvents');
|
|
}
|
|
if (Capabilities.supportsTouchEvents()) {
|
|
events.push('TouchEvents');
|
|
}
|
|
if (Capabilities.supportsPointerEvents()) {
|
|
events.push('PointerEvents');
|
|
}
|
|
supported_events.innerHTML = 'Supported Events: ' + events.join(', ');
|
|
}
|
|
|
|
static testAll() {
|
|
this.testUserAgent();
|
|
this.testDevicePixelRatio();
|
|
this.testMultiTouchTable();
|
|
this.testSupportedEvents();
|
|
}
|
|
}
|
|
|
|
/* Optional global variables, needed in DocTests. */
|
|
window.Capabilities = Capabilities;
|
|
window.CapabilitiesTests = CapabilitiesTests;
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/**
|
|
* A base class for scatter specific events.
|
|
*
|
|
* @constructor
|
|
* @param {name} String - The name of the event
|
|
* @param {target} Object - The target of the event
|
|
*/
|
|
class BaseEvent {
|
|
constructor(name, target) {
|
|
this.name = name;
|
|
this.target = target;
|
|
}
|
|
}
|
|
|
|
// Event types
|
|
const START = 'onStart';
|
|
const UPDATE = 'onUpdate';
|
|
const END = 'onEnd';
|
|
|
|
/**
|
|
* A scatter event that describes how the scatter has changed.
|
|
*
|
|
* @constructor
|
|
* @param {target} Object - The target scatter of the event
|
|
* @param {optional} Object - Optional parameter
|
|
*/
|
|
class ScatterEvent extends BaseEvent {
|
|
constructor(
|
|
target,
|
|
{ translate = { x: 0, y: 0 }, scale = null, rotate = 0, about = null, fast = false, type = null } = {}
|
|
) {
|
|
super('scatterTransformed', { target: target });
|
|
this.translate = translate;
|
|
this.scale = scale;
|
|
this.rotate = rotate;
|
|
this.about = about;
|
|
this.fast = fast;
|
|
this.type = type;
|
|
}
|
|
|
|
toString() {
|
|
return (
|
|
"Event('scatterTransformed', scale: " + this.scale + ' about: ' + this.about.x + ', ' + this.about.y + ')'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A scatter resize event that describes how the scatter has changed.
|
|
*
|
|
* @constructor
|
|
* @param {target} Object - The target scatter of the event
|
|
* @param {optional} Object - Optional parameter
|
|
*/
|
|
class ResizeEvent extends BaseEvent {
|
|
constructor(target, { width = 0, height = 0 } = {}) {
|
|
super('scatterResized', { width: width, height: height });
|
|
this.width = width;
|
|
this.height = height;
|
|
}
|
|
|
|
toString() {
|
|
return 'Event(scatterResized width: ' + this.width + 'height: ' + this.height + ')'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A abstract base class that implements the throwable behavior of a scatter
|
|
* object.
|
|
*
|
|
* @constructor
|
|
*/
|
|
class Throwable {
|
|
constructor({
|
|
movableX = true,
|
|
movableY = true,
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
autoThrow = true,
|
|
onThrowFinished = null
|
|
} = {}) {
|
|
this.movableX = movableX;
|
|
this.movableY = movableY;
|
|
this.throwVisibility = throwVisibility;
|
|
this.throwDamping = throwDamping;
|
|
this.autoThrow = autoThrow;
|
|
this.velocities = [];
|
|
this.velocity = null;
|
|
this.timestamp = null;
|
|
this.onThrowFinished = onThrowFinished;
|
|
//console.log("onThrowFinished", onThrowFinished)
|
|
}
|
|
|
|
observeVelocity() {
|
|
this.lastframe = performance.now();
|
|
}
|
|
|
|
addVelocity(delta, buffer = 5) {
|
|
let t = performance.now();
|
|
let dt = t - this.lastframe;
|
|
this.lastframe = t;
|
|
if (dt > 0) {
|
|
// Avoid division by zero errors later on
|
|
// and consider the number of involved pointers sind addVelocity will be called by the
|
|
// onMove events
|
|
let velocity = {
|
|
t: t,
|
|
dt: dt,
|
|
dx: delta.x / delta.number,
|
|
dy: delta.y / delta.number
|
|
};
|
|
this.velocities.push(velocity);
|
|
while (this.velocities.length > buffer) {
|
|
this.velocities.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
meanVelocity(milliseconds = 30) {
|
|
this.addVelocity({ x: 0, y: 0, number: 1 });
|
|
let sum = { x: 0, y: 0 };
|
|
let count = 0;
|
|
let t = 0;
|
|
for (let i = this.velocities.length - 1; i > 0; i--) {
|
|
let v = this.velocities[i];
|
|
t += v.dt;
|
|
let nv = { x: v.dx / v.dt, y: v.dy / v.dt };
|
|
sum = Points.add(sum, nv);
|
|
count += 1;
|
|
if (t > milliseconds) {
|
|
break
|
|
}
|
|
}
|
|
if (count === 0) return sum // empty vector
|
|
return Points.multiplyScalar(sum, 1 / count)
|
|
}
|
|
|
|
killAnimation() {
|
|
this.velocity = null;
|
|
this.velocities = [];
|
|
}
|
|
|
|
startThrow() {
|
|
this.velocity = this.meanVelocity();
|
|
if (this.velocity != null) {
|
|
// Call next velocity to ansure that specializations
|
|
// that use keepOnStage are called
|
|
this.velocity = this.nextVelocity(this.velocity);
|
|
if (this.autoThrow) this.animateThrow(performance.now());
|
|
} else {
|
|
this.onDragComplete();
|
|
}
|
|
}
|
|
|
|
_throwDeltaTime() {
|
|
let t = performance.now();
|
|
let dt = t - this.lastframe;
|
|
this.lastframe = t;
|
|
return dt
|
|
}
|
|
|
|
animateThrow(time) {
|
|
if (this.velocity != null) {
|
|
let dt = this._throwDeltaTime();
|
|
// console.log("animateThrow", dt)
|
|
let next = this.nextVelocity(this.velocity);
|
|
let prevLength = Points.length(this.velocity);
|
|
let nextLength = Points.length(next);
|
|
if (nextLength > prevLength) {
|
|
let factor = nextLength / prevLength;
|
|
next = Points.multiplyScalar(next, 1 / factor);
|
|
console.log('Prevent acceleration', factor, this.velocity, next);
|
|
}
|
|
this.velocity = next;
|
|
let d = Points.multiplyScalar(this.velocity, dt);
|
|
this._move(d);
|
|
|
|
this.onDragUpdate(d);
|
|
if (dt == 0 || this.needsAnimation()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
} else {
|
|
if (this.isOutside()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
}
|
|
}
|
|
}
|
|
this.onDragComplete();
|
|
if (this.onThrowFinished != null) {
|
|
this.onThrowFinished();
|
|
}
|
|
}
|
|
|
|
needsAnimation() {
|
|
if (this.velocity == null) {
|
|
return false
|
|
}
|
|
return Points.length(this.velocity) > 0.01
|
|
}
|
|
|
|
nextVelocity(velocity) {
|
|
// Must be overwritten: computes the changed velocity. Implement
|
|
// damping, collison detection, etc. here
|
|
let next = Points.multiplyScalar(velocity, this.throwDamping);
|
|
return {
|
|
x: this.movableX ? next.x : 0,
|
|
y: this.movableY ? next.y : 0
|
|
}
|
|
}
|
|
|
|
_move(delta) {
|
|
// Overwrite if necessary
|
|
}
|
|
|
|
onDragComplete() {
|
|
// Overwrite if necessary
|
|
}
|
|
|
|
onDragUpdate(delta) {
|
|
// Overwrite if necessary
|
|
}
|
|
}
|
|
|
|
class AbstractScatter extends Throwable {
|
|
constructor({
|
|
minScale = 0.1,
|
|
maxScale = 1.0,
|
|
startScale = 1.0,
|
|
autoBringToFront = true,
|
|
autoThrow = true,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
resizable = false,
|
|
movableX = true,
|
|
movableY = true,
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
overdoScaling = 1,
|
|
mouseZoomFactor = 1.1,
|
|
rotationDegrees = null,
|
|
rotation = null,
|
|
onTransform = null,
|
|
interactive = true,
|
|
onClose = null,
|
|
onThrowFinished = null,
|
|
scaleAutoClose = false,
|
|
scaleCloseThreshold = 0.1,
|
|
scaleCloseBuffer = 0.05,
|
|
maxRotation = Angle.degree2radian(5),
|
|
minInteractionDistance = 0,
|
|
useLowPassFilter = false
|
|
} = {}) {
|
|
if (rotationDegrees != null && rotation != null) {
|
|
throw new Error('Use rotationDegrees or rotation but not both')
|
|
} else if (rotation != null) {
|
|
rotationDegrees = Angle.radian2degree(rotation);
|
|
} else if (rotationDegrees == null) {
|
|
rotationDegrees = 0;
|
|
}
|
|
super({
|
|
movableX,
|
|
movableY,
|
|
throwVisibility,
|
|
throwDamping,
|
|
autoThrow,
|
|
onThrowFinished
|
|
});
|
|
|
|
/**
|
|
* Closes the card when the minScale is reached and the
|
|
* card is released. Card can be saved by scaling it up again.
|
|
*/
|
|
this.scaleAutoClose = scaleAutoClose;
|
|
this.scaleCloseThreshold = scaleCloseThreshold;
|
|
this.scaleCloseBuffer = scaleCloseBuffer;
|
|
this.scaleAutoCloseTimeout = null;
|
|
|
|
this.interactive = interactive;
|
|
this.startRotationDegrees = rotationDegrees;
|
|
this.startScale = startScale; // Needed to reset object
|
|
this.minScale = minScale;
|
|
this.maxScale = maxScale;
|
|
this.maxRotation = maxRotation;
|
|
this.overdoScaling = overdoScaling;
|
|
this.translatable = translatable;
|
|
if (!translatable) {
|
|
this.movableX = false;
|
|
this.movableY = false;
|
|
}
|
|
this.scalable = scalable;
|
|
this.rotatable = rotatable;
|
|
this.resizable = resizable;
|
|
this.mouseZoomFactor = mouseZoomFactor;
|
|
this.autoBringToFront = autoBringToFront;
|
|
this.useLowPassFilter = useLowPassFilter;
|
|
this.minInteractionDistance = minInteractionDistance;
|
|
if (useLowPassFilter) {
|
|
this.rotateLPF = new LowPassFilter();
|
|
this.zoomLPF = new LowPassFilter();
|
|
this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
}
|
|
this.dragging = false;
|
|
this.onTransform = onTransform != null ? [onTransform] : null;
|
|
this.onClose = onClose != null ? [onClose] : null;
|
|
}
|
|
|
|
addCloseEventCallback(callback) {
|
|
if (this.onClose == null) {
|
|
this.onClose = [];
|
|
}
|
|
this.onClose.push(callback);
|
|
}
|
|
|
|
addTransformEventCallback(callback) {
|
|
if (this.onTransform == null) {
|
|
this.onTransform = [];
|
|
}
|
|
this.onTransform.push(callback);
|
|
}
|
|
|
|
startGesture(interaction) {
|
|
this.bringToFront();
|
|
this.killAnimation();
|
|
this.observeVelocity();
|
|
if (this.useLowPassFilter) {
|
|
this.rotateLPF.clear();
|
|
this.zoomLPF.clear();
|
|
this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
}
|
|
return true
|
|
}
|
|
|
|
close() {
|
|
this._callCloseCallbacks();
|
|
this._removeCallbacks();
|
|
this._removeSelfFromScatterContainer();
|
|
}
|
|
|
|
_callCloseCallbacks() {
|
|
if (this.onClose) {
|
|
this.onClose.forEach(callback => callback(this));
|
|
}
|
|
}
|
|
|
|
_removeCallbacks() {
|
|
this.onClose = [];
|
|
this.onTransform = [];
|
|
}
|
|
|
|
_removeSelfFromScatterContainer() {
|
|
// Removes self from container when it's closed.
|
|
if (this.container) {
|
|
this.container.remove(this);
|
|
}
|
|
}
|
|
|
|
gesture(interaction) {
|
|
let delta = interaction.delta();
|
|
|
|
if (delta != null) {
|
|
this.addVelocity(delta);
|
|
let rotate = delta.rotate;
|
|
let zoom = delta.zoom;
|
|
if (this.maxRotation != null) {
|
|
if (Math.abs(rotate) > this.maxRotation) {
|
|
rotate = 0;
|
|
}
|
|
}
|
|
if (this.useLowPassFilter) {
|
|
rotate = this.rotateLPF.next(rotate);
|
|
zoom = this.zoomLPF.next(zoom);
|
|
}
|
|
if (delta.distance < this.minInteractionDistance) {
|
|
let ratio = delta.distance / this.minInteractionDistance;
|
|
rotate *= ratio;
|
|
let zoomDelta = zoom - 1;
|
|
zoomDelta *= ratio;
|
|
zoom = 1 + zoomDelta;
|
|
}
|
|
|
|
this.transform(delta, zoom, rotate, delta.about);
|
|
if (zoom != 1) this.interactionAnchor = delta.about;
|
|
}
|
|
}
|
|
|
|
get polygon() {
|
|
let w2 = (this.width * this.scale) / 2;
|
|
let h2 = (this.height * this.scale) / 2;
|
|
let center = this.center;
|
|
let polygon = new Polygon(center);
|
|
polygon.addPoint({ x: -w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: h2 });
|
|
polygon.addPoint({ x: -w2, y: h2 });
|
|
polygon.rotate(this.rotation);
|
|
return polygon
|
|
}
|
|
|
|
isOutside() {
|
|
let stagePolygon = this.containerPolygon;
|
|
if (stagePolygon == null) return false
|
|
let polygon = this.polygon;
|
|
if (polygon == null) return false
|
|
let result = stagePolygon.intersectsWith(polygon);
|
|
return result === false || result.overlap < this.throwVisibility
|
|
}
|
|
|
|
recenter() {
|
|
// Return a small vector that guarantees that the scatter is moving
|
|
// towards the center of the stage
|
|
let center = this.center;
|
|
let target = this.container.center;
|
|
let delta = Points.subtract(target, center);
|
|
return Points.normalize(delta)
|
|
}
|
|
|
|
nextVelocity(velocity) {
|
|
return this.keepOnStage(velocity)
|
|
}
|
|
|
|
bouncing() {
|
|
// Implements the bouncing behavior of the scatter. Moves the scatter
|
|
// to the center of the stage if the scatter is outside the stage or
|
|
// not within the limits of the throwVisibility.
|
|
|
|
let stagePolygon = this.containerPolygon;
|
|
let polygon = this.polygon;
|
|
let result = stagePolygon.intersectsWith(polygon);
|
|
if (result === false || result.overlap < this.throwVisibility) {
|
|
let cv = this.recenter();
|
|
let recentered = false;
|
|
while (result === false || result.overlap < this.throwVisibility) {
|
|
polygon.center.x += cv.x;
|
|
polygon.center.y += cv.y;
|
|
this._move(cv);
|
|
result = stagePolygon.intersectsWith(polygon);
|
|
recentered = true;
|
|
}
|
|
return recentered
|
|
}
|
|
return false
|
|
}
|
|
|
|
keepOnStage(velocity, collision = 0.5) {
|
|
let stagePolygon = this.containerPolygon;
|
|
// UO: since keepOnStage is called in nextVelocity we need to
|
|
// ensure a return value
|
|
if (!stagePolygon) return { x: 0, y: 0 }
|
|
let polygon = this.polygon;
|
|
let bounced = this.bouncing();
|
|
if (bounced) {
|
|
let stage = this.containerBounds;
|
|
let x = this.center.x;
|
|
let y = this.center.y;
|
|
let dx = this.movableX ? velocity.x : 0;
|
|
let dy = this.movableY ? velocity.y : 0;
|
|
let factor = this.throwDamping;
|
|
// if (recentered) {
|
|
if (x < 0) {
|
|
dx = -dx;
|
|
factor = collision;
|
|
}
|
|
if (x > stage.width) {
|
|
dx = -dx;
|
|
factor = collision;
|
|
}
|
|
if (y < 0) {
|
|
dy = -dy;
|
|
factor = collision;
|
|
}
|
|
if (y > stage.height) {
|
|
dy = -dy;
|
|
factor = collision;
|
|
}
|
|
// }
|
|
return Points.multiplyScalar({ x: dx, y: dy }, factor)
|
|
}
|
|
return super.nextVelocity(velocity)
|
|
}
|
|
|
|
endGesture(interaction) {
|
|
this.startThrow();
|
|
this._checkAutoClose();
|
|
}
|
|
|
|
_checkAutoClose() {
|
|
if (this.scaleAutoClose)
|
|
if (this.scale < this.minScale + this.scaleCloseThreshold - this.scaleCloseBuffer) {
|
|
this.zoom(this.minScale, {
|
|
animate: 0.2,
|
|
onComplete: this.close.bind(this)
|
|
});
|
|
} else if (this.scale < this.minScale + this.scaleCloseThreshold) {
|
|
this.zoom(this.minScale + this.scaleCloseThreshold, {
|
|
animate: 0.4
|
|
});
|
|
}
|
|
}
|
|
|
|
rotateDegrees(degrees, anchor) {
|
|
let rad = Angle.degree2radian(degrees);
|
|
this.rotate(rad, anchor);
|
|
}
|
|
|
|
rotate(rad, anchor) {
|
|
this.transform({ x: 0, y: 0 }, 1.0, rad, anchor);
|
|
}
|
|
|
|
move(d, { animate = 0 } = {}) {
|
|
if (this.translatable) {
|
|
if (animate > 0) {
|
|
let startPos = this.position;
|
|
TweenLite.to(this, animate, {
|
|
x: '+=' + d.x,
|
|
y: '+=' + d.y,
|
|
/* scale: scale, uo: not defined, why was this here? */
|
|
onUpdate: e => {
|
|
let p = this.position;
|
|
let dx = p.x - startPos.x;
|
|
let dy = p.x - startPos.y;
|
|
this.onMoved(dx, dy);
|
|
}
|
|
});
|
|
} else {
|
|
this._move(d);
|
|
this.onMoved(d.x, d.y);
|
|
}
|
|
}
|
|
}
|
|
|
|
moveTo(p, { animate = 0 } = {}) {
|
|
let c = this.origin;
|
|
let delta = Points.subtract(p, c);
|
|
this.move(delta, { animate: animate });
|
|
}
|
|
|
|
centerAt(p, { animate = 0 } = {}) {
|
|
let c = this.center;
|
|
let delta = Points.subtract(p, c);
|
|
this.move(delta, { animate: animate });
|
|
}
|
|
|
|
zoom(scale, { animate = 0, about = null, delay = 0, x = null, y = null, onComplete = null } = {}) {
|
|
let anchor = about || this.center;
|
|
if (scale != this.scale) {
|
|
if (animate > 0) {
|
|
TweenLite.to(this, animate, {
|
|
scale: scale,
|
|
delay: delay,
|
|
onComplete: onComplete,
|
|
onUpdate: this.onZoomed.bind(this)
|
|
});
|
|
} else {
|
|
this.scale = scale;
|
|
this.onZoomed(anchor);
|
|
}
|
|
}
|
|
}
|
|
|
|
_move(delta) {
|
|
this.x += this.movableX ? delta.x : 0;
|
|
this.y += this.movableX ? delta.y : 0;
|
|
}
|
|
|
|
transform(translate, zoom, rotate, anchor) {
|
|
let delta = {
|
|
x: this.movableX ? translate.x : 0,
|
|
y: this.movableY ? translate.y : 0
|
|
};
|
|
if (this.resizable) var vzoom = zoom;
|
|
if (!this.translatable) delta = { x: 0, y: 0 };
|
|
if (!this.rotatable) rotate = 0;
|
|
if (!this.scalable) zoom = 1.0;
|
|
if (zoom == 1.0 && rotate == 0) {
|
|
this._move(delta);
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: delta,
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: anchor,
|
|
fast: false,
|
|
type: UPDATE
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
return
|
|
}
|
|
let origin = this.rotationOrigin;
|
|
let beta = Points.angle(origin, anchor);
|
|
let distance = Points.distance(origin, anchor);
|
|
let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale(zoom);
|
|
|
|
let newOrigin = Points.arc(anchor, beta + rotate, distance * thresholdedZoom);
|
|
let extra = Points.subtract(newOrigin, origin);
|
|
let offset = Points.subtract(anchor, origin);
|
|
this._move(offset);
|
|
this.scale = newScale;
|
|
this.rotation += rotate;
|
|
offset = Points.negate(offset);
|
|
offset = Points.add(offset, extra);
|
|
offset = Points.add(offset, translate);
|
|
this._move(offset);
|
|
|
|
delta.x += extra.x;
|
|
delta.y += extra.y;
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: delta,
|
|
scale: newScale,
|
|
rotate: rotate,
|
|
about: anchor
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
if (this.resizable) {
|
|
this.resizeAfterTransform(vzoom);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For a given zoom, a new scale is calculated, taking
|
|
* min and max scale into account.
|
|
*
|
|
* @param {number} zoom - The zoom factor, to scale the object with.
|
|
* @returns {object} - Returns an object containing the a value for a valid scale and the corrected zoom factor.
|
|
*/
|
|
calculateScale(zoom) {
|
|
let scale = this.scale * zoom;
|
|
|
|
let minScale = this.minScale / this.overdoScaling;
|
|
let maxScale = this.maxScale * this.overdoScaling;
|
|
if (scale < minScale) {
|
|
scale = minScale;
|
|
zoom = scale / this.scale;
|
|
}
|
|
if (scale > maxScale) {
|
|
scale = maxScale;
|
|
zoom = scale / this.scale;
|
|
}
|
|
|
|
if (this.scaleAutoClose) this._updateTransparency();
|
|
|
|
return { zoom, scale }
|
|
}
|
|
|
|
_updateTransparency() {
|
|
if (this.scale < this.minScale + this.scaleCloseThreshold) {
|
|
let transparency = this.calculateScaleTransparency();
|
|
this.element.style.opacity = transparency;
|
|
} else this.element.style.opacity = 1;
|
|
}
|
|
|
|
calculateScaleTransparency() {
|
|
let transparency = (this.scale - this.minScale) / this.scaleCloseThreshold;
|
|
transparency = transparency > 1 ? 1 : transparency < 0 ? 0 : transparency;
|
|
return transparency
|
|
}
|
|
|
|
resizeAfterTransform(zoom) {
|
|
// Overwrite this in subclasses.
|
|
}
|
|
|
|
validScale(scale) {
|
|
scale = Math.max(scale, this.minScale);
|
|
scale = Math.min(scale, this.maxScale);
|
|
return scale
|
|
}
|
|
|
|
animateZoomBounce(dt = 1) {
|
|
if (this.zoomAnchor != null) {
|
|
let zoom = 1;
|
|
let amount = Math.min(0.01, (0.3 * dt) / 100000.0);
|
|
if (this.scale < this.minScale) zoom = 1 + amount;
|
|
if (this.scale > this.maxScale) zoom = 1 - amount;
|
|
if (zoom != 1) {
|
|
this.transform({ x: 0, y: 0 }, zoom, 0, this.zoomAnchor);
|
|
requestAnimationFrame(dt => {
|
|
this.animateZoomBounce(dt);
|
|
});
|
|
return
|
|
}
|
|
this.zoomAnchor = null;
|
|
}
|
|
}
|
|
|
|
checkScaling(about, delay = 0) {
|
|
this.zoomAnchor = about;
|
|
clearTimeout(this.animateZoomBounce.bind(this));
|
|
setTimeout(this.animateZoomBounce.bind(this), delay);
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (event.claimedByScatter) {
|
|
if (event.claimedByScatter != this) return
|
|
}
|
|
this.killAnimation();
|
|
this.targetScale = null;
|
|
let direction = event.detail < 0 || event.wheelDelta > 0;
|
|
let globalPoint = { x: event.clientX, y: event.clientY };
|
|
let centerPoint = this.mapPositionToContainerPoint(globalPoint);
|
|
if (event.shiftKey) {
|
|
let degrees = direction ? 5 : -5;
|
|
let rad = Angle.degree2radian(degrees);
|
|
return this.transform({ x: 0, y: 0 }, 1.0, rad, centerPoint)
|
|
}
|
|
const zoomFactor = this.mouseZoomFactor;
|
|
let zoom = direction ? zoomFactor : 1 / zoomFactor;
|
|
this.transform({ x: 0, y: 0 }, zoom, 0, centerPoint);
|
|
this.checkScaling(centerPoint, 200);
|
|
|
|
if (this.scaleAutoClose) {
|
|
if (this.scale <= this.minScale + this.scaleCloseThreshold) {
|
|
if (this.scaleAutoCloseTimeout) clearTimeout(this.scaleAutoCloseTimeout);
|
|
this.scaleAutoCloseTimeout = setTimeout(() => {
|
|
this._checkAutoClose();
|
|
}, 600);
|
|
}
|
|
this._updateTransparency();
|
|
}
|
|
}
|
|
|
|
onStart(event, interaction) {
|
|
if (this.startGesture(interaction)) {
|
|
this.dragging = true;
|
|
this.interactionAnchor = null;
|
|
}
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: 0, y: 0 },
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: null,
|
|
fast: false,
|
|
type: START
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onMove(event, interaction) {
|
|
/** As long as mouseout && mouseleave interrupt we cannot be sure that
|
|
* dragging remains correct.
|
|
*/
|
|
if (this.dragging) {
|
|
this.gesture(interaction);
|
|
}
|
|
}
|
|
|
|
onEnd(event, interaction) {
|
|
console.log('Scatter.onEnd', this.dragging);
|
|
if (interaction.isFinished()) {
|
|
this.endGesture(interaction);
|
|
this.dragging = false;
|
|
for (let key of interaction.ended.keys()) {
|
|
if (interaction.isTap(key)) {
|
|
console.log('Scatter.isTap');
|
|
let point = interaction.ended.get(key);
|
|
this.onTap(event, interaction, point);
|
|
}
|
|
}
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: 0, y: 0 },
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: null,
|
|
fast: false,
|
|
type: END
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
let about = this.interactionAnchor;
|
|
if (about != null) {
|
|
this.checkScaling(about, 100);
|
|
}
|
|
}
|
|
|
|
//onTap(event, interaction, point) {}
|
|
|
|
onTap(event, interaction, point) {
|
|
console.log('AbstractScatter.onTap', this.tapDelegate, interaction);
|
|
if (this.tapDelegate) {
|
|
Events$1.stop(event);
|
|
this.tapDelegate.tap(event, 'scatter');
|
|
}
|
|
}
|
|
|
|
onDragUpdate(delta) {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
fast: true,
|
|
translate: delta,
|
|
scale: this.scale,
|
|
about: this.currentAbout,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onDragComplete() {
|
|
if (this.onTransform) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
about: this.currentAbout,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onMoved(dx, dy, about) {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: dx, y: dy },
|
|
about: about,
|
|
fast: true,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onResizing() {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onZoomed(about) {
|
|
if (this.scaleAutoClose) this._updateTransparency();
|
|
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
about: about,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
class DOMScatter extends AbstractScatter {
|
|
constructor(
|
|
element,
|
|
container,
|
|
{
|
|
startScale = 1.0,
|
|
minScale = 0.1,
|
|
maxScale = 1.0,
|
|
overdoScaling = 1.5,
|
|
autoBringToFront = true,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
movableX = true,
|
|
movableY = true,
|
|
rotationDegrees = null,
|
|
rotation = null,
|
|
onTransform = null,
|
|
transformOrigin = 'center center',
|
|
// extras which are in part needed
|
|
x = 0,
|
|
y = 0,
|
|
width = null, // required
|
|
height = null, // required
|
|
resizable = false,
|
|
tapDelegate = null,
|
|
triggerSVGClicks = false,
|
|
allowClickDistance = 44,
|
|
verbose = true,
|
|
onResize = null,
|
|
touchAction = 'none',
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
autoThrow = true,
|
|
scaleAutoClose = false,
|
|
onClose = null,
|
|
scaleCloseThreshold = 0.1,
|
|
scaleCloseBuffer = 0.05,
|
|
useLowPassFilter = false,
|
|
maxRotation = Angle.degree2radian(15),
|
|
minInteractionDistance = 200
|
|
} = {}
|
|
) {
|
|
super({
|
|
minScale,
|
|
maxScale,
|
|
startScale,
|
|
overdoScaling,
|
|
autoBringToFront,
|
|
translatable,
|
|
scalable,
|
|
rotatable,
|
|
movableX,
|
|
movableY,
|
|
resizable,
|
|
rotationDegrees,
|
|
rotation,
|
|
onTransform,
|
|
throwVisibility,
|
|
throwDamping,
|
|
autoThrow,
|
|
scaleAutoClose,
|
|
scaleCloseThreshold,
|
|
scaleCloseBuffer,
|
|
onClose,
|
|
useLowPassFilter,
|
|
maxRotation,
|
|
minInteractionDistance
|
|
});
|
|
if (container == null || width == null || height == null) {
|
|
throw new Error('Invalid value: null')
|
|
}
|
|
element.scatter = this;
|
|
this.element = element;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.oldX = 0;
|
|
this.oldY = 0;
|
|
this.meanX = x;
|
|
this.meanY = y;
|
|
this.width = width;
|
|
this.height = height;
|
|
this.throwVisibility = Math.min(width, height, throwVisibility);
|
|
this.container = container;
|
|
this.tapDelegate = tapDelegate;
|
|
this.scale = startScale;
|
|
this.rotationDegrees = this.startRotationDegrees;
|
|
this.transformOrigin = transformOrigin;
|
|
this.initialValues = {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
scale: startScale,
|
|
rotation: this.startRotationDegrees,
|
|
transformOrigin: transformOrigin
|
|
};
|
|
this.tapNodes = new Map();
|
|
|
|
// For tweenlite we need initial values in _gsTransform
|
|
TweenLite.set(element, this.initialValues);
|
|
this.onResize = onResize;
|
|
this.verbose = verbose;
|
|
if (touchAction !== null) {
|
|
Elements$1.setStyle(element, { touchAction });
|
|
}
|
|
this.resizeButton = null;
|
|
if (resizable) {
|
|
let button = document.createElement('div');
|
|
button.style.position = 'absolute';
|
|
button.style.right = '0px';
|
|
button.style.bottom = '0px';
|
|
button.style.width = '50px';
|
|
button.style.height = '50px';
|
|
button.className = 'interactiveElement';
|
|
this.element.appendChild(button);
|
|
|
|
button.addEventListener('pointerdown', e => {
|
|
this.startResize(e);
|
|
});
|
|
|
|
button.addEventListener('pointermove', e => {
|
|
this.resize(e);
|
|
});
|
|
|
|
button.addEventListener('pointerup', e => {
|
|
this.stopResize(e);
|
|
});
|
|
this.resizeButton = button;
|
|
}
|
|
if (tapDelegate) {
|
|
tapDelegate.handleClicks();
|
|
}
|
|
container.add(this);
|
|
}
|
|
|
|
/** Returns geometry data as object. **/
|
|
getState() {
|
|
return {
|
|
scale: this.scale,
|
|
x: this.x,
|
|
y: this.y,
|
|
rotation: this.rotation
|
|
}
|
|
}
|
|
|
|
close() {
|
|
super.close();
|
|
let parent = this.element.parentNode;
|
|
if (parent) parent.removeChild(this.element);
|
|
}
|
|
|
|
get rotationOrigin() {
|
|
return this.center
|
|
}
|
|
|
|
get x() {
|
|
return this._x
|
|
}
|
|
|
|
get y() {
|
|
return this._y
|
|
}
|
|
|
|
set x(value) {
|
|
this._x = value;
|
|
TweenLite.set(this.element, { x: value });
|
|
}
|
|
|
|
set y(value) {
|
|
this._y = value;
|
|
TweenLite.set(this.element, { y: value });
|
|
}
|
|
|
|
get position() {
|
|
let transform = this.element._gsTransform;
|
|
let x = transform.x;
|
|
let y = transform.y;
|
|
return { x, y }
|
|
}
|
|
|
|
get origin() {
|
|
let p = this.fromNodeToPage(0, 0);
|
|
return Points.fromPageToNode(this.container.element, p)
|
|
}
|
|
|
|
get bounds() {
|
|
let stage = this.container.element.getBoundingClientRect();
|
|
let rect = this.element.getBoundingClientRect();
|
|
return {
|
|
top: rect.top - stage.top,
|
|
left: rect.left - stage.left,
|
|
width: rect.width,
|
|
height: rect.height
|
|
}
|
|
}
|
|
|
|
get center() {
|
|
let r = this.bounds;
|
|
let w2 = r.width / 2;
|
|
let h2 = r.height / 2;
|
|
// if (this.resizable) {
|
|
// w2 *= this.scale
|
|
// h2 *= this.scale
|
|
// }
|
|
var x = r.left + w2;
|
|
var y = r.top + h2;
|
|
return { x, y }
|
|
}
|
|
|
|
set rotation(radians) {
|
|
let rad = radians; // Angle.normalize(radians)
|
|
let degrees = Angle.radian2degree(rad);
|
|
TweenLite.set(this.element, { rotation: degrees });
|
|
this._rotation = rad;
|
|
}
|
|
|
|
set rotationDegrees(degrees) {
|
|
let deg = degrees; // Angle.normalizeDegree(degrees)
|
|
TweenLite.set(this.element, { rotation: deg });
|
|
this._rotation = Angle.degree2radian(deg);
|
|
}
|
|
|
|
get rotation() {
|
|
return this._rotation
|
|
}
|
|
|
|
get rotationDegrees() {
|
|
return this._rotation
|
|
}
|
|
|
|
set scale(scale) {
|
|
TweenLite.set(this.element, {
|
|
scale: scale,
|
|
transformOrigin: this.transformOrigin
|
|
});
|
|
this._scale = scale;
|
|
}
|
|
|
|
get scale() {
|
|
return this._scale
|
|
}
|
|
|
|
get containerBounds() {
|
|
return this.container.bounds
|
|
}
|
|
|
|
get containerPolygon() {
|
|
return this.container.polygon
|
|
}
|
|
|
|
mapPositionToContainerPoint(point) {
|
|
return this.container.mapPositionToPoint(point)
|
|
}
|
|
|
|
capture(event) {
|
|
return true
|
|
}
|
|
|
|
reset() {
|
|
TweenLite.set(this.element, this.initialValues);
|
|
}
|
|
|
|
hide() {
|
|
TweenLite.to(this.element, 0.1, {
|
|
display: 'none',
|
|
onComplete: e => {
|
|
this.element.parentNode.removeChild(this.element);
|
|
}
|
|
});
|
|
}
|
|
|
|
show() {
|
|
TweenLite.set(this.element, { display: 'block' });
|
|
}
|
|
|
|
showAt(p, rotationDegrees) {
|
|
TweenLite.set(this.element, {
|
|
display: 'block',
|
|
x: p.x,
|
|
y: p.y,
|
|
rotation: rotationDegrees,
|
|
transformOrigin: this.transformOrigin
|
|
});
|
|
}
|
|
|
|
bringToFront() {
|
|
// this.element.parentNode.appendChild(this.element)
|
|
// uo: On Chome and Electon appendChild leads to flicker
|
|
TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ });
|
|
}
|
|
|
|
isDescendant(parent, child) {
|
|
let node = child.parentNode;
|
|
while (node != null) {
|
|
if (node == parent) {
|
|
return true
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false
|
|
}
|
|
|
|
fromPageToNode(x, y) {
|
|
return Points.fromPageToNode(this.element, { x, y })
|
|
}
|
|
|
|
fromNodeToPage(x, y) {
|
|
return Points.fromNodeToPage(this.element, { x, y })
|
|
}
|
|
|
|
_move(delta) {
|
|
// UO: We need to keep TweenLite's _gsTransform and the private
|
|
// _x and _y attributes aligned
|
|
let x = this.element._gsTransform.x;
|
|
let y = this.element._gsTransform.y;
|
|
if (this.movableX) {
|
|
x += delta.x;
|
|
}
|
|
if (this.movableY) {
|
|
y += delta.y;
|
|
}
|
|
this._x = x;
|
|
this._y = y;
|
|
TweenLite.set(this.element, { x: x, y: y });
|
|
}
|
|
|
|
resizeAfterTransform(zoom) {
|
|
if (this.onResize) {
|
|
let w = this.width * this.scale;
|
|
let h = this.height * this.scale;
|
|
let event = new ResizeEvent(this, { width: w, height: h });
|
|
this.onResize(event);
|
|
}
|
|
}
|
|
|
|
startResize(e) {
|
|
e.preventDefault();
|
|
let event = new CustomEvent('resizeStarted');
|
|
|
|
let oldPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
this.bringToFront();
|
|
|
|
this.element.style.transformOrigin = '0% 0%';
|
|
|
|
let newPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
|
|
let offset = Points.subtract(oldPostition, newPostition);
|
|
|
|
this.oldX = e.clientX;
|
|
this.oldY = e.clientY;
|
|
|
|
e.target.setAttribute('resizing', 'true');
|
|
e.target.setPointerCapture(e.pointerId);
|
|
|
|
TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
|
|
TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
|
|
resize(e) {
|
|
e.preventDefault();
|
|
|
|
let rotation = Angle.radian2degree(this.rotation);
|
|
rotation = (rotation + 360) % 360;
|
|
let event = new CustomEvent('resized');
|
|
if (e.target.getAttribute('resizing') == 'true') {
|
|
let deltaX = e.clientX - this.oldX;
|
|
let deltaY = e.clientY - this.oldY;
|
|
|
|
let r = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
|
|
let phi = Angle.radian2degree(Math.atan2(deltaX, deltaY));
|
|
|
|
phi = (phi + 630) % 360;
|
|
let rot = (rotation + 90 + 630) % 360;
|
|
|
|
let diffAngle = (0 + rot + 360) % 360;
|
|
let phiCorrected = (phi + diffAngle + 360) % 360;
|
|
|
|
let resizeW = r * Math.cos(Angle.degree2radian(phiCorrected));
|
|
let resizeH = -r * Math.sin(Angle.degree2radian(phiCorrected));
|
|
|
|
if (
|
|
(this.element.offsetWidth + resizeW) / this.scale > (this.width * 0.5) / this.scale &&
|
|
(this.element.offsetHeight + resizeH) / this.scale > (this.height * 0.3) / this.scale
|
|
)
|
|
TweenLite.to(this.element, 0, {
|
|
width: this.element.offsetWidth + resizeW / this.scale,
|
|
height: this.element.offsetHeight + resizeH / this.scale
|
|
});
|
|
|
|
this.oldX = e.clientX;
|
|
this.oldY = e.clientY;
|
|
this.onResizing();
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
stopResize(e) {
|
|
e.preventDefault();
|
|
|
|
let event = new CustomEvent('resizeEnded');
|
|
let oldPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
this.element.style.transformOrigin = '50% 50%';
|
|
let newPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
let offset = Points.subtract(oldPostition, newPostition);
|
|
|
|
TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
|
|
TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });
|
|
|
|
e.target.setAttribute('resizing', 'false');
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
DOMScatter.zIndex = 1000;
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
class CardWrapper extends Object {
|
|
constructor(domNode, { triggerSVGClicks = true, allowClickDistance = 44 } = {}) {
|
|
super();
|
|
this.domNode = domNode;
|
|
this.triggerSVGClicks = triggerSVGClicks;
|
|
this.allowClickDistance = allowClickDistance;
|
|
this.tapNodes = new Map();
|
|
this.tapHandler = new Map();
|
|
}
|
|
|
|
handleClicks() {
|
|
this.domNode.addEventListener(
|
|
'click',
|
|
event => {
|
|
if (event.isTrusted) {
|
|
Events$1.stop(event);
|
|
if (this.triggerSVGClicks && this.isSVGNode(event.target)) {
|
|
this.tap(event, 'triggerSVGClicks');
|
|
}
|
|
}
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
handleClicksAsTaps() {
|
|
this.domNode.addEventListener(
|
|
'click',
|
|
event => {
|
|
if (event.isTrusted) {
|
|
Events$1.stop(event);
|
|
}
|
|
this.tap(event);
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
isClickPrevented(node) {
|
|
if (node == null) {
|
|
return false
|
|
}
|
|
if (node.style && node.style.pointerEvents == 'none') {
|
|
return true
|
|
}
|
|
return this.isClickPrevented(node.parentNode)
|
|
}
|
|
|
|
isClickable(node) {
|
|
if (node == null) return false
|
|
// console.log("isClickable", node, this.isClickPrevented(node))
|
|
if (this.isClickPrevented(node)) {
|
|
return false
|
|
}
|
|
if (node.tagName == 'A' && node.hasAttribute('href')) return true
|
|
if (node.hasAttribute('onclick')) return true
|
|
return false
|
|
}
|
|
|
|
hasClickHandler(node) {
|
|
if (node == null) return false
|
|
if (this.tapNodes.has(node)) return true
|
|
for (let [selector, handler] of this.tapHandler.entries()) {
|
|
for (let obj of this.domNode.querySelectorAll(selector)) {
|
|
if (node == obj) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Returns an array of all active nodes.
|
|
* Unfortunately we cannot search for all nodes with an attached 'click' event listener
|
|
* See https://stackoverflow.com/questions/11455515/how-to-check-whether-dynamically-attached-event-listener-exists-or-not
|
|
* Therefore we can only detect the following standard cases:
|
|
* I. All clickable objects like activeNodes
|
|
* II. Objects that have been attached a click handler by the scatter itself via
|
|
*/
|
|
activeNodes() {
|
|
let result = [];
|
|
for (let node of this.domNode.querySelectorAll('*')) {
|
|
if (this.isClickable(node)) result.push(node);
|
|
if (this.hasClickHandler(node)) result.push(node);
|
|
}
|
|
return result
|
|
}
|
|
|
|
nearestActive(event) {
|
|
let element = this.domNode;
|
|
let activeNodes = this.activeNodes();
|
|
let globalClick = event.center ? event.center : { x: event.x, y: event.y };
|
|
let localClick = Points.fromPageToNode(element, globalClick);
|
|
|
|
let clickRects = activeNodes.map(link => {
|
|
let rect = link.getBoundingClientRect();
|
|
let topLeft = Points.fromPageToNode(element, rect);
|
|
let center = Points.fromPageToNode(element, {
|
|
x: rect.x + rect.width / 2,
|
|
y: rect.y + rect.height / 2
|
|
});
|
|
return {
|
|
x: topLeft.x,
|
|
y: topLeft.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
center,
|
|
link
|
|
}
|
|
});
|
|
|
|
let distances = [];
|
|
clickRects.forEach(rect => {
|
|
let distance = Points.distanceToRect(localClick, rect);
|
|
distances.push(parseInt(distance));
|
|
});
|
|
|
|
let closestClickIndex = distances.indexOf(Math.min(...distances));
|
|
let closestClickable = activeNodes[closestClickIndex];
|
|
if (distances[closestClickIndex] < this.allowClickDistance) {
|
|
return closestClickable
|
|
}
|
|
return null
|
|
}
|
|
|
|
isSVGNode(node) {
|
|
return node.ownerSVGElement || node.tagName == 'svg'
|
|
}
|
|
|
|
simulateClick(node, event) {
|
|
/* https://stackoverflow.com/questions/49564905/is-it-possible-to-use-click-function-on-svg-tags-i-tried-element-click-on-a
|
|
proposes the dispatchEvent solution. But this leads to problems in flippable.html hiding the back page.
|
|
Therefore we use the original click event (see constructor). */
|
|
if (this.isSVGNode(node)) {
|
|
if (this.triggerSVGClicks) {
|
|
let click = new Event('click');
|
|
node.dispatchEvent(click);
|
|
}
|
|
return
|
|
}
|
|
node.click();
|
|
}
|
|
|
|
nodeTapped(node, event) {
|
|
console.log('nodeTapped', node, this.isClickable(node));
|
|
if (this.isClickable(node)) {
|
|
this.simulateClick(node, event);
|
|
return true
|
|
}
|
|
if (this.tapNodes.has(node)) {
|
|
let handler = this.tapNodes.get(node);
|
|
handler(event, node);
|
|
return true
|
|
}
|
|
for (let [selector, handler] of this.tapHandler.entries()) {
|
|
console.log('nodeTapped', selector);
|
|
for (let obj of this.domNode.querySelectorAll(selector)) {
|
|
if (node == obj) {
|
|
handler(event, node);
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
tap(event, calledBy = 'unknown') {
|
|
if (event.isTrusted) {
|
|
let node = this.nearestActive(event);
|
|
console.log('tap', node);
|
|
this.nodeTapped(node, event);
|
|
|
|
/* let node = document.elementFromPoint(event.clientX, event.clientY)
|
|
if (!this.nodeTapped(node, event)) {
|
|
node = this.nearestActive(event)
|
|
this.nodeTapped(node, event)
|
|
} */
|
|
}
|
|
}
|
|
|
|
onTap(objOrSelector, handler) {
|
|
if (typeof objOrSelector == 'string') {
|
|
this.tapHandler.set(objOrSelector, handler);
|
|
} else {
|
|
this.tapNodes.set(objOrSelector, handler);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
class CardLoader {
|
|
constructor(
|
|
src,
|
|
{
|
|
x = 0,
|
|
y = 0,
|
|
width = 1000,
|
|
height = 800,
|
|
maxWidth = null,
|
|
maxHeight = null,
|
|
scale = 1,
|
|
minScale = 0.5,
|
|
maxScale = 1.5,
|
|
rotation = 0
|
|
} = {}
|
|
) {
|
|
this.src = src;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.scale = scale;
|
|
this.rotation = rotation;
|
|
this.maxScale = maxScale;
|
|
this.minScale = minScale;
|
|
this.wantedWidth = width;
|
|
this.wantedHeight = height;
|
|
this.maxWidth = maxWidth != null ? maxWidth : window.innerWidth;
|
|
this.maxHeight = maxHeight != null ? maxHeight : window.innerHeight;
|
|
this.addedNode = null;
|
|
}
|
|
|
|
unload() {
|
|
if (this.addedNode) {
|
|
this.addedNode.remove();
|
|
this.addedNode = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
class DOMFlip {
|
|
constructor(
|
|
domScatterContainer,
|
|
flipTemplate,
|
|
frontLoader,
|
|
backLoader,
|
|
{
|
|
closeOnMinScale = false,
|
|
flipDuration = 1,
|
|
fadeDuration = 0.2,
|
|
overdoScaling = 1,
|
|
autoLoad = false,
|
|
center = null,
|
|
preloadBack = false,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
tapDelegateFactory = null,
|
|
onFront = null,
|
|
onBack = null,
|
|
onClose = null,
|
|
onUpdate = null,
|
|
onRemoved = null,
|
|
onLoaded = null
|
|
} = {}
|
|
) {
|
|
this.domScatterContainer = domScatterContainer;
|
|
this.id = getId();
|
|
this.flipDuration = flipDuration;
|
|
this.fadeDuration = fadeDuration;
|
|
this.closeOnMinScale = closeOnMinScale;
|
|
this.flipTemplate = flipTemplate;
|
|
this.frontLoader = frontLoader;
|
|
this.backLoader = backLoader;
|
|
this.translatable = translatable;
|
|
this.scalable = scalable;
|
|
this.rotatable = rotatable;
|
|
this.tapDelegateFactory = tapDelegateFactory;
|
|
this.onFrontFlipped = onFront;
|
|
this.onBackFlipped = onBack;
|
|
this.onClose = onClose;
|
|
this.onRemoved = onRemoved;
|
|
this.onUpdate = onUpdate;
|
|
this.onLoaded = onLoaded;
|
|
this.center = center;
|
|
this.preloadBack = preloadBack;
|
|
this.overdoScaling = overdoScaling;
|
|
if (autoLoad) {
|
|
this.load();
|
|
}
|
|
}
|
|
|
|
load() {
|
|
return new Promise((resolve, reject) => {
|
|
let t = this.flipTemplate;
|
|
let dom = this.domScatterContainer.element;
|
|
let wrapper = t.content.querySelector('.flipWrapper');
|
|
wrapper.id = this.id;
|
|
let clone = document.importNode(t.content, true);
|
|
dom.appendChild(clone);
|
|
// We cannot use the document fragment itself because it
|
|
// is not part of the main dom tree. After the appendChild
|
|
// call we can access the new dom element by id
|
|
this.cardWrapper = dom.querySelector('#' + this.id);
|
|
let front = this.cardWrapper.querySelector('.front');
|
|
this.frontLoader.load(front).then(loader => {
|
|
this.frontLoaded(loader).then(obj => {
|
|
if (this.onLoaded) this.onLoaded();
|
|
resolve(this);
|
|
});
|
|
});
|
|
})
|
|
}
|
|
|
|
frontLoaded(loader) {
|
|
return new Promise((resolve, reject) => {
|
|
let scatter = new DOMScatter(this.cardWrapper, this.domScatterContainer, {
|
|
x: loader.x,
|
|
y: loader.y,
|
|
startScale: loader.scale,
|
|
scale: loader.scale,
|
|
maxScale: loader.maxScale,
|
|
minScale: loader.minScale,
|
|
width: loader.wantedWidth,
|
|
height: loader.wantedHeight,
|
|
rotation: loader.rotation,
|
|
translatable: this.translatable,
|
|
scalable: this.scalable,
|
|
rotatable: this.rotatable,
|
|
overdoScaling: this.overdoScaling,
|
|
tapDelegate: this.tapDelegateFactory ? new this.tapDelegateFactory(this.cardWrapper) : null
|
|
});
|
|
|
|
if (this.center) {
|
|
scatter.centerAt(this.center);
|
|
}
|
|
|
|
if (this.closeOnMinScale) {
|
|
const removeOnMinScale = function() {
|
|
if (scatter.scale <= scatter.minScale) {
|
|
this.flippable.close();
|
|
|
|
// 'Disable' overdoscaling to avoid weird jumps on close.
|
|
scatter.minScale /= scatter.overdoScaling;
|
|
scatter.overdoScaling = 1;
|
|
|
|
//Remove callback
|
|
if (scatter.onTransform) {
|
|
let callbackIdx = scatter.onTransform.indexOf(removeOnMinScale);
|
|
scatter.onTransform.splice(callbackIdx, 1);
|
|
}
|
|
}
|
|
}.bind(this);
|
|
scatter.addTransformEventCallback(removeOnMinScale);
|
|
}
|
|
|
|
let flippable = new DOMFlippable(this.cardWrapper, scatter, this);
|
|
let back = this.cardWrapper.querySelector('.back');
|
|
|
|
if (this.preloadBack) {
|
|
this.backLoader.load(back).then(loader => {
|
|
this.setupFlippable(flippable, loader);
|
|
});
|
|
}
|
|
this.flippable = flippable;
|
|
resolve(this);
|
|
})
|
|
}
|
|
|
|
centerAt(p) {
|
|
this.center = p;
|
|
this.flippable.centerAt(p);
|
|
}
|
|
|
|
zoom(scale) {
|
|
this.flippable.zoom(scale);
|
|
}
|
|
|
|
setupFlippable(flippable, loader) {
|
|
flippable.wantedWidth = loader.wantedWidth;
|
|
flippable.wantedHeight = loader.wantedHeight;
|
|
flippable.wantedScale = loader.scale;
|
|
flippable.minScale = loader.minScale;
|
|
flippable.maxScale = loader.maxScale;
|
|
flippable.scaleButtons();
|
|
}
|
|
|
|
start({ targetCenter = null } = {}) {
|
|
this.flippable.showFront();
|
|
if (this.preloadBack) {
|
|
this.flippable.start({ duration: this.flipDuration, targetCenter });
|
|
} else {
|
|
let back = this.cardWrapper.querySelector('.back');
|
|
let flippable = this.flippable;
|
|
this.backLoader.load(back).then(loader => {
|
|
this.setupFlippable(flippable, loader);
|
|
flippable.start({ duration: this.flipDuration, targetCenter });
|
|
});
|
|
}
|
|
}
|
|
|
|
fadeOutAndRemove() {
|
|
TweenLite.to(this.cardWrapper, this.fadeDuration, {
|
|
opacity: 0,
|
|
onComplete: () => {
|
|
this.cardWrapper.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
closed() {
|
|
this.unload();
|
|
}
|
|
|
|
unload() {
|
|
if (!this.preloadBack) {
|
|
this.backLoader.unload();
|
|
}
|
|
}
|
|
}
|
|
|
|
class DOMFlippable {
|
|
constructor(element, scatter, flip) {
|
|
// Set log to console.log or a custom log function
|
|
// define data structures to store our touchpoints in
|
|
|
|
this.element = element;
|
|
this.flip = flip;
|
|
this.card = element.querySelector('.flipCard');
|
|
this.front = element.querySelector('.front');
|
|
this.back = element.querySelector('.back');
|
|
this.flipped = false;
|
|
this.scatter = scatter;
|
|
this.onFrontFlipped = flip.onFrontFlipped;
|
|
this.onBackFlipped = flip.onBackFlipped;
|
|
this.onClose = flip.onClose;
|
|
this.onRemoved = flip.onRemoved;
|
|
this.onUpdate = flip.onUpdate;
|
|
|
|
this.wantedWidth = scatter.width;
|
|
this.wantedHeight = scatter.height;
|
|
this.wantedScale = scatter.scale;
|
|
this.minScale = scatter.minScale;
|
|
this.maxScale = scatter.maxScale;
|
|
|
|
this.flipDuration = flip.flipDuration;
|
|
this.fadeDuration = flip.fadeDuration;
|
|
scatter.addTransformEventCallback(this.scatterTransformed.bind(this));
|
|
|
|
TweenLite.set(this.element, { perspective: 5000 });
|
|
TweenLite.set(this.card, { transformStyle: 'preserve-3d' });
|
|
TweenLite.set(this.back, { rotationY: -180 });
|
|
TweenLite.set([this.back, this.front], {
|
|
backfaceVisibility: 'hidden',
|
|
perspective: 5000
|
|
});
|
|
TweenLite.set(this.front, { visibility: 'visible' });
|
|
this.infoBtn = element.querySelector('.infoBtn');
|
|
this.backBtn = element.querySelector('.backBtn');
|
|
this.closeBtn = element.querySelector('.closeBtn');
|
|
/* Buttons are not guaranteed to exist. */
|
|
if (scatter.tapDelegate == null) {
|
|
let tapDelegate = new CardWrapper(element);
|
|
scatter.tapDelegate = tapDelegate;
|
|
}
|
|
if (this.infoBtn) {
|
|
scatter.tapDelegate.onTap(this.infoBtn, event => {
|
|
this.flip.start();
|
|
});
|
|
this.enable(this.infoBtn);
|
|
}
|
|
if (this.backBtn) {
|
|
scatter.tapDelegate.onTap(this.backBtn, event => {
|
|
this.start();
|
|
});
|
|
}
|
|
if (this.closeBtn) {
|
|
scatter.tapDelegate.onTap(this.closeBtn, event => {
|
|
this.close();
|
|
});
|
|
this.enable(this.closeBtn);
|
|
}
|
|
this.scaleButtons();
|
|
this.bringToFront();
|
|
}
|
|
|
|
close() {
|
|
this.disable(this.infoBtn);
|
|
this.disable(this.closeBtn);
|
|
if (this.onClose) {
|
|
this.onClose(this);
|
|
this.flip.closed();
|
|
} else {
|
|
this.scatter.zoom(0.1, {
|
|
animate: this.fadeDuration,
|
|
onComplete: () => {
|
|
this.element.remove();
|
|
this.flip.closed();
|
|
if (this.onRemoved) {
|
|
this.onRemoved.call(this);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
showFront() {
|
|
TweenLite.set(this.front, { visibility: 'visible' });
|
|
}
|
|
|
|
centerAt(p) {
|
|
this.scatter.centerAt(p);
|
|
}
|
|
|
|
zoom(scale) {
|
|
this.scatter.zoom(scale);
|
|
}
|
|
|
|
get buttonScale() {
|
|
let iscale = 1.0;
|
|
|
|
if (this.scatter != null) {
|
|
let scale = this.scatter.scale || 1;
|
|
iscale = 1.0 / scale;
|
|
}
|
|
return iscale
|
|
}
|
|
|
|
scaleButtons() {
|
|
TweenLite.set([this.infoBtn, this.backBtn, this.closeBtn], {
|
|
scale: this.buttonScale
|
|
});
|
|
}
|
|
|
|
bringToFront() {
|
|
this.scatter.bringToFront();
|
|
TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ });
|
|
}
|
|
|
|
clickInfo() {
|
|
this.bringToFront();
|
|
this.infoBtn.click();
|
|
}
|
|
|
|
scatterTransformed(event) {
|
|
this.scaleButtons();
|
|
}
|
|
|
|
targetRotation(alpha) {
|
|
let ortho = 90;
|
|
let rest = alpha % ortho;
|
|
let delta = 0.0;
|
|
if (rest > ortho / 2.0) {
|
|
delta = ortho - rest;
|
|
} else {
|
|
delta = -rest;
|
|
}
|
|
return delta
|
|
}
|
|
|
|
infoValues(info) {
|
|
let startX = this.element._gsTransform.x;
|
|
let startY = this.element._gsTransform.y;
|
|
let startAngle = this.element._gsTransform.rotation;
|
|
let startScale = this.element._gsTransform.scaleX;
|
|
let w = this.element.style.width;
|
|
let h = this.element.style.height;
|
|
// eslint-disable-next-line no-console
|
|
console.log(info, startX, startY, startAngle, startScale, w, h);
|
|
}
|
|
|
|
show(element, duration = 0, alpha = 1) {
|
|
if (element) {
|
|
TweenLite.to(element, duration, { autoAlpha: alpha }); // visibility: 'visible', display: 'initial'})
|
|
}
|
|
}
|
|
|
|
hide(element, duration = 0, alpha = 0) {
|
|
if (element) {
|
|
TweenLite.to(element, duration, { autoAlpha: alpha }); // {visibility: 'hidden', display: 'none'})
|
|
}
|
|
}
|
|
|
|
enable(button) {
|
|
this.show(button, this.fadeDuration);
|
|
if (button) {
|
|
TweenLite.set(button, { pointerEvents: 'auto' });
|
|
}
|
|
}
|
|
|
|
disable(button) {
|
|
this.hide(button, this.fadeDuration);
|
|
if (button) {
|
|
TweenLite.set(button, { pointerEvents: 'none' });
|
|
}
|
|
}
|
|
|
|
start({ targetCenter = null } = {}) {
|
|
this.bringToFront();
|
|
console.log('flippable start', this.flipped);
|
|
if (!this.flipped) {
|
|
this.startX = this.element._gsTransform.x;
|
|
this.startY = this.element._gsTransform.y;
|
|
this.startAngle = this.element._gsTransform.rotation;
|
|
this.startScale = this.element._gsTransform.scaleX;
|
|
this.startWidth = this.element.style.width;
|
|
this.startHeight = this.element.style.height;
|
|
this.scatterStartWidth = this.scatter.width;
|
|
this.scatterStartHeight = this.scatter.height;
|
|
this.show(this.back);
|
|
this.disable(this.infoBtn);
|
|
this.disable(this.closeBtn);
|
|
} else {
|
|
this.show(this.front, this.fadeDuration);
|
|
this.disable(this.backBtn);
|
|
}
|
|
let { scalable, translatable, rotatable } = this.scatter;
|
|
this.saved = { scalable, translatable, rotatable };
|
|
this.scatter.scalable = false;
|
|
this.scatter.translatable = false;
|
|
this.scatter.rotatable = false;
|
|
this.scatter.killAnimation();
|
|
|
|
this.flipped = !this.flipped;
|
|
let targetY = this.flipped ? 180 : 0;
|
|
let targetZ = this.flipped ? this.startAngle + this.targetRotation(this.startAngle) : this.startAngle;
|
|
let targetScale = this.flipped ? this.wantedScale : this.startScale;
|
|
let w = this.flipped ? this.wantedWidth : this.startWidth;
|
|
let h = this.flipped ? this.wantedHeight : this.startHeight;
|
|
let dw = this.wantedWidth - this.scatter.width;
|
|
let dh = this.wantedHeight - this.scatter.height;
|
|
let tc = targetCenter;
|
|
let xx = tc != null ? tc.x - w / 2 : this.startX - dw / 2;
|
|
let yy = tc != null ? tc.y - h / 2 : this.startY - dh / 2;
|
|
let x = this.flipped ? xx : this.startX;
|
|
let y = this.flipped ? yy : this.startY;
|
|
|
|
let onUpdate = this.onUpdate !== null ? () => this.onUpdate(this) : null;
|
|
TweenLite.to(this.card, this.flipDuration, {
|
|
rotationY: targetY,
|
|
ease: Power1.easeOut,
|
|
transformOrigin: '50% 50%',
|
|
onUpdate,
|
|
onComplete: e => {
|
|
if (this.flipped) {
|
|
//this.hide(this.front)
|
|
this.enable(this.backBtn);
|
|
this.show(this.backBtn);
|
|
|
|
if (this.onFrontFlipped) {
|
|
this.onFrontFlipped(this);
|
|
}
|
|
} else {
|
|
if (this.onBackFlipped == null) {
|
|
this.enable(this.infoBtn, this.fadeDuration);
|
|
this.enable(this.closeBtn, this.fadeDuration);
|
|
} else {
|
|
this.onBackFlipped(this);
|
|
}
|
|
this.flip.unload();
|
|
}
|
|
this.scatter.scale = targetScale;
|
|
this.scaleButtons();
|
|
this.scatter.rotationDegrees = targetZ;
|
|
this.scatter.width = this.flipped ? w : this.scatterStartWidth;
|
|
this.scatter.height = this.flipped ? h : this.scatterStartHeight;
|
|
|
|
let { scalable, translatable, rotatable } = this.saved;
|
|
this.scatter.scalable = scalable;
|
|
this.scatter.translatable = translatable;
|
|
this.scatter.rotatable = rotatable;
|
|
},
|
|
force3D: true
|
|
});
|
|
// See https://greensock.com/forums/topic/7997-rotate-the-shortest-way/
|
|
TweenLite.to(this.element, this.flipDuration / 2, {
|
|
scale: targetScale,
|
|
ease: Power1.easeOut,
|
|
rotationZ: targetZ + '_short',
|
|
transformOrigin: '50% 50%',
|
|
width: w,
|
|
height: h,
|
|
x: x,
|
|
y: y,
|
|
onComplete: e => {
|
|
if (this.flipped) {
|
|
this.hide(this.front);
|
|
// this.hide(this.infoBtn)
|
|
} else {
|
|
this.hide(this.back);
|
|
// this.show(this.infoBtn)
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ES Lint */
|
|
/* globals PIXI, console*/
|
|
|
|
const registeredTiles = new Map();
|
|
/** Implements a baseTexture cache. The last textures are kept for reuse */
|
|
let keepTextures = 0;
|
|
const keptTextures = [];
|
|
const lateTextures = new Map();
|
|
let lastSweepTime = 0;
|
|
let sweepInterval = 2000.0;
|
|
|
|
/** The current Tile implementation simply uses PIXI.Sprites.
|
|
*
|
|
* BTW: PIXI.extras.TilingSprite is not appropriate. It should be used for
|
|
* repeating patterns.
|
|
**/
|
|
class Tile extends PIXI.Sprite {
|
|
constructor(texture, url) {
|
|
super(texture);
|
|
this.url = url;
|
|
this.register(url);
|
|
}
|
|
|
|
/**
|
|
* Static method to enable keeping of base textures
|
|
*
|
|
* @static
|
|
* @param {*} value
|
|
* @memberof Tile
|
|
*/
|
|
static enableKeepTextures(value = 1000) {
|
|
keepTextures = value;
|
|
}
|
|
|
|
/**
|
|
* Returns true iff the url is pending
|
|
*
|
|
* @static
|
|
* @param {*} url
|
|
* @returns
|
|
* @memberof Tile
|
|
*/
|
|
/*static isPending(url) {
|
|
return pendingTiles.has(url) && pendingTiles.get(url) > 0
|
|
} */
|
|
|
|
static isObsolete(url) {
|
|
if (registeredTiles.has(url) && registeredTiles.get(url) > 0) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Loads a tile from image using the PIXI.Texture.from method.
|
|
*
|
|
* @static
|
|
* @param {*} imageId
|
|
* @param {*} crossorigin
|
|
* @param {*} scaleMode
|
|
* @returns
|
|
* @memberof Tile
|
|
*/
|
|
static fromImage(imageId, crossorigin, scaleMode) {
|
|
return new Tile(PIXI.Texture.from(imageId, crossorigin, scaleMode), imageId)
|
|
}
|
|
|
|
/**
|
|
* Registers the tile in the global reference counter for textures
|
|
*
|
|
* @param {*} url
|
|
* @param {boolean} [debug=false]
|
|
* @memberof Tile
|
|
*/
|
|
register(url, debug = false) {
|
|
//Tile.unschedule(url)
|
|
if (registeredTiles.has(url)) {
|
|
let tiles = registeredTiles.get(url);
|
|
tiles.add(this);
|
|
if (debug) console.log('Tile.register', url, tiles.size);
|
|
} else {
|
|
registeredTiles.set(url, new Set([this]));
|
|
if (debug) console.log('Tile.register', url, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregisters the rile in the global reference counter for textures
|
|
*
|
|
* @returns {number} The number of how often a texture is used.
|
|
* @memberof Tile
|
|
*/
|
|
unregister() {
|
|
let tiles = registeredTiles.get(this.url);
|
|
tiles.delete(this);
|
|
if (tiles.size == 0) {
|
|
registeredTiles.delete(this.url);
|
|
return 0
|
|
}
|
|
return tiles.size
|
|
}
|
|
|
|
/**
|
|
* Destroys this sprite and optionally its texture and children
|
|
*
|
|
* @param {*} options Part of the PIXI API, but ignored in the implementation
|
|
* @memberof Tile
|
|
*/
|
|
destroy(options, debug = false) {
|
|
let count = this.unregister();
|
|
|
|
if (keepTextures > 0) {
|
|
keptTextures.push({ url: this.url, texture: this.texture });
|
|
|
|
let opts = { children: true, texture: false, baseTexture: false };
|
|
if (debug) console.log('Tile.destroy', registeredTiles.size, opts);
|
|
super.destroy(opts);
|
|
|
|
while (keptTextures.length > keepTextures) {
|
|
let { url, texture } = keptTextures.shift();
|
|
if (Tile.isObsolete(url)) {
|
|
texture.destroy(true); // Destroy base as well
|
|
if (debug) console.log('Destroying texture and baseTexture', url);
|
|
}
|
|
}
|
|
} else {
|
|
// No longer registered and not pending
|
|
if (count <= 0) {
|
|
// && !Tile.isPending(this.url)
|
|
let opts = { children: true, texture: true, baseTexture: true };
|
|
super.destroy(opts);
|
|
if (debug) console.log('Tile.destroy', registeredTiles.size, opts);
|
|
} else {
|
|
let opts = { children: true, texture: false, baseTexture: false };
|
|
if (debug) console.log('Tile.destroy', registeredTiles.size, opts);
|
|
super.destroy(opts);
|
|
}
|
|
if (this.parent != null) {
|
|
// UO: Emit warning and remove
|
|
console.warn('Destroying tile with parent. Hiding instead');
|
|
this.visible = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns an available texture that can be reused
|
|
*
|
|
* @param {*} url
|
|
* @returns
|
|
* @memberof Tile
|
|
*/
|
|
static textureAvailable(url) {
|
|
if (registeredTiles.has(url)) {
|
|
let tiles = registeredTiles.get(url);
|
|
for (let tile of tiles.values()) {
|
|
//console.log("Reusing cached texture", tile.parent)
|
|
return tile.texture
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Specialized renderWebGL to avoid freezing system
|
|
*
|
|
* @param {*} renderer
|
|
* @memberof Tile
|
|
*/
|
|
renderWebGL(renderer) {
|
|
try {
|
|
super.renderWebGL(renderer);
|
|
} catch (e) {
|
|
// We want persistent logging here
|
|
Logging.error('Error in Tile.renderWebGL: ' + e.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes lately arrived texture if they have not been touched in the meanwhile.
|
|
*
|
|
* @static
|
|
* @memberof Tile
|
|
*/
|
|
static sweepLateTextures() {
|
|
let now = performance.now();
|
|
if (now > lastSweepTime + sweepInterval) {
|
|
lastSweepTime = now;
|
|
let count = 0;
|
|
for (let [url, texture] of lateTextures.entries()) {
|
|
if (texture) {
|
|
let base = texture.baseTexture;
|
|
if (base != null && base.touched == 0) {
|
|
texture.destroy(true);
|
|
//console.info("Sweeping ", url)
|
|
count += 1;
|
|
lateTextures.delete(url);
|
|
}
|
|
}
|
|
}
|
|
if (count > 0) console.log('Sweeping textures', count);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Texture received too late. We do not need it.
|
|
* @param {*} url
|
|
* @param {*} texture
|
|
*/
|
|
static lateTexture(url, texture) {
|
|
lateTextures.set(url, texture);
|
|
//console.warn("Tile.lateTexture")
|
|
// We cannot destroy the texture since we got errors in t.renderWebGL.
|
|
// Perhaps we can destroy unsed textures later on
|
|
Tile.sweepLateTextures();
|
|
}
|
|
|
|
static printInfos() {
|
|
let references = new Map();
|
|
let multiples = 0;
|
|
for (let [url, tiles] of registeredTiles.entries()) {
|
|
let count = tiles.size;
|
|
references.set(url, count);
|
|
if (count > 1) {
|
|
multiples += 1;
|
|
}
|
|
}
|
|
console.log({ multiples, references });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A Tile Loader component that can be plugged into a Tiles Layer.
|
|
*/
|
|
class TileLoader {
|
|
constructor(tiles) {
|
|
this.debug = false;
|
|
this.tiles = tiles;
|
|
this.setup();
|
|
}
|
|
|
|
/** Setup collections and instance vars. */
|
|
setup() {
|
|
this.map = new Map(); // Map {url : [ col, row]}
|
|
this.loading = new Set(); // Set url
|
|
this.loaded = new Map(); // Map {url : sprite }
|
|
this.loadQueue = [];
|
|
}
|
|
|
|
/** Schedules a tile url for loading. The loading itself must be triggered
|
|
by a call to loadOneTile or loadAll
|
|
|
|
* @param {String} url - the url of the texture / tile
|
|
* @param {Number} col - the tile col
|
|
* @param {Number} row - the tile row
|
|
**/
|
|
schedule(url, col, row) {
|
|
if (this.loaded.has(url)) return false
|
|
if (this.loading.has(url)) return false
|
|
|
|
//Tile.schedule(url)
|
|
this.map.set(url, [col, row]);
|
|
this.loading.add(url);
|
|
this.loadQueue.push(url);
|
|
return true
|
|
}
|
|
|
|
unschedule(url) {
|
|
if (this.loaded.has(url)) this.loaded.delete(url);
|
|
if (this.loading.has(url)) this.loading.delete(url);
|
|
//Tile.unschedule(url)
|
|
this.loadQueue = this.loadQueue.filter(item => item != url);
|
|
}
|
|
|
|
/** Cancels loading by clearing the load queue **/
|
|
cancel() {
|
|
this.loadQueue = [];
|
|
this.loading.clear();
|
|
}
|
|
|
|
/** Destroys alls collections. **/
|
|
destroy() {
|
|
this.setup();
|
|
}
|
|
|
|
/** Private method. Informs the tile layer about a texture for a given url.
|
|
* Creates the sprite for the loaded texture and informs the tile layer.
|
|
* @param {String} url - the url
|
|
* @param {Object} texture - the loaded resource
|
|
**/
|
|
_textureAvailable(url, col, row, texture) {
|
|
let tile = this.loaded.get(url);
|
|
if (tile != null) {
|
|
console.warn('Tile already loaded');
|
|
tile.unregister();
|
|
}
|
|
try {
|
|
tile = new Tile(texture, url);
|
|
this.loaded.set(url, tile);
|
|
this.tiles.tileAvailable(tile, col, row, url);
|
|
} catch (error) {
|
|
console.warn('Tile loading error', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uses the PIXI Loader but can be replaced with othe loaders implementing
|
|
* the public methods without underscore.
|
|
* Calls the Tiles.tileAvailable method if the texture is available.
|
|
**/
|
|
class PIXITileLoader extends TileLoader {
|
|
constructor(tiles, compression) {
|
|
super(tiles);
|
|
this.destroyed = false;
|
|
this.loader = new PIXI.Loader();
|
|
this.loader.on('load', this._onLoaded.bind(this));
|
|
this.loader.on('error', this._onError.bind(this));
|
|
if (compression) {
|
|
this.loader.use(PIXI.compressedTextures.ImageParser.use);
|
|
}
|
|
}
|
|
|
|
schedule(url, col, row) {
|
|
// Overwritten schedule to avoid BaseTexture and Texture already loaded errors.
|
|
if (this.loaded.has(url)) return false
|
|
if (this.loading.has(url)) return false
|
|
|
|
//Tile.schedule(url)
|
|
let reusableTexture = Tile.textureAvailable(url);
|
|
if (reusableTexture) {
|
|
if (this.debug) console.log('Texture reusable', reusableTexture);
|
|
this._textureAvailable(url, col, row, reusableTexture);
|
|
return false
|
|
}
|
|
let texture = PIXI.utils.TextureCache[url];
|
|
if (texture) {
|
|
if (this.debug) console.log('Texture already loaded', texture);
|
|
this._textureAvailable(url, col, row, texture);
|
|
return false
|
|
}
|
|
let base = PIXI.utils.BaseTextureCache[url];
|
|
if (base) {
|
|
if (this.debug) console.log('BaseTexture already loaded', base);
|
|
let texture = new PIXI.Texture(base);
|
|
this._textureAvailable(url, col, row, texture);
|
|
return false
|
|
}
|
|
return super.schedule(url, col, row)
|
|
}
|
|
|
|
/** Load one and only one of the scheduled tiles **/
|
|
loadOneTile() {
|
|
if (this.destroyed) return
|
|
this._loadOneTile();
|
|
}
|
|
|
|
/** Load all scheduled tiles **/
|
|
loadAll() {
|
|
if (this.destroyed) return
|
|
this._loadAllTiles();
|
|
}
|
|
|
|
/** Destroys the loader completly **/
|
|
destroy() {
|
|
this.destroyed = true;
|
|
super.destroy();
|
|
try {
|
|
this.loader.reset();
|
|
} catch (error) {
|
|
console.warn('Error while resetting loader', error);
|
|
}
|
|
}
|
|
|
|
_onError(loader, error) {
|
|
console.warn('Cannot load', error);
|
|
}
|
|
|
|
/** Private method. Called by the PIXI loader after each successfull
|
|
* loading of a single tile.
|
|
* Creates the sprite for the loaded texture and informs the tile layer.
|
|
* @param {Object} loader - the loader instance
|
|
* @param {Object} resource - the loaded resource with url and texture attr
|
|
**/
|
|
_onLoaded(loader, resource) {
|
|
if (this.destroyed) {
|
|
let texture = resource.texture;
|
|
let url = resource.url;
|
|
Tile.lateTexture(url, texture);
|
|
console.warn('Received resource after destroy', texture);
|
|
return
|
|
}
|
|
try {
|
|
let [col, row] = this.map.get(resource.url);
|
|
this._textureAvailable(resource.url, col, row, resource.texture);
|
|
} catch (err) {
|
|
console.warn('Texture unavailable: ' + err.message);
|
|
}
|
|
}
|
|
|
|
/** Private method: loads one tile from the queue. **/
|
|
_loadOneTile(retry = 1) {
|
|
//console.log("_loadOneTile")
|
|
if (this.destroyed) {
|
|
//console.warn("_loadOneTile after destroy")
|
|
return
|
|
}
|
|
if (this.loader.loading) {
|
|
setTimeout(() => {
|
|
this._loadOneTile();
|
|
}, retry);
|
|
return
|
|
}
|
|
if (this.loadQueue.length > 0) {
|
|
let url = this.loadQueue.pop();
|
|
this.loader.add(url, url);
|
|
this.loader.load();
|
|
}
|
|
}
|
|
|
|
/** Private method: loads all tiles from the queue in batches. Batches are
|
|
helpfull to avoid loading tiles that are no longer needed because the
|
|
user has already zoomed to a different level.**/
|
|
_loadAllTiles(batchSize = 8, retry = 16) {
|
|
if (this.destroyed) {
|
|
return
|
|
}
|
|
if (this.loadQueue.length > 0) {
|
|
if (this.loader.loading) {
|
|
//console.log("Loader busy", this.loadQueue.length)
|
|
setTimeout(() => {
|
|
this._loadAllTiles();
|
|
}, retry);
|
|
return
|
|
}
|
|
let i = 0;
|
|
let urls = [];
|
|
while (i < batchSize && this.loadQueue.length > 0) {
|
|
let url = this.loadQueue.pop();
|
|
if (!this.loaded.has(url)) {
|
|
let resource = this.loader.resources[url];
|
|
if (resource) {
|
|
console.log('Resource already added', url);
|
|
} else {
|
|
urls.push(url);
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
this.loader.add(urls).load(() => {
|
|
this._loadAllTiles();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uses Workers but can be replaced with other loaders implementing
|
|
* the public methods without underscore.
|
|
* Calls the Tiles.tileAvailable method if the texture is available.
|
|
**/
|
|
class WorkerTileLoader extends TileLoader {
|
|
constructor(tiles, workerPath) {
|
|
super(tiles);
|
|
|
|
let worker = (this.worker = new Worker(workerPath));
|
|
|
|
worker.onmessage = event => {
|
|
if (event.data.success) {
|
|
let { url, col, row, buffer } = event.data;
|
|
//console.log("WorkerTileLoader.loaded", url, buffer)
|
|
//let CompressedImage = PIXI.compressedTextures.CompressedImage
|
|
let compressed = PIXI.compressedTextures.loadFromArrayBuffer(buffer, url);
|
|
let base = new PIXI.BaseTexture(compressed);
|
|
let texture = new PIXI.Texture(base);
|
|
this._textureAvailable(url, col, row, texture);
|
|
}
|
|
};
|
|
}
|
|
|
|
loadOne() {
|
|
if (this.loadQueue.length > 0) {
|
|
let url = this.loadQueue.pop();
|
|
let [col, row] = this.map.get(url);
|
|
let tile = [col, row, url];
|
|
this.worker.postMessage({ command: 'load', tiles: [tile] });
|
|
}
|
|
}
|
|
|
|
loadAll() {
|
|
let tiles = [];
|
|
while (this.loadQueue.length > 0) {
|
|
let url = this.loadQueue.pop();
|
|
let [col, row] = this.map.get(url);
|
|
tiles.push([col, row, url]);
|
|
}
|
|
this.worker.postMessage({ command: 'load', tiles });
|
|
}
|
|
|
|
cancel() {
|
|
super.cancel();
|
|
this.worker.postMessage({ command: 'abort' });
|
|
}
|
|
|
|
destroy() {
|
|
this.worker.postMessage({ command: 'abort' });
|
|
this.worker.terminate();
|
|
this.worker = null;
|
|
super.destroy();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A layer of tiles that represents a zoom level of a DeepZoomImage as a grid
|
|
* of sprites.
|
|
* @constructor
|
|
* @param {number} level - the zoom level of the tile layer
|
|
* @param {DeepZoomImage} view - the zoomable image the layer belongs to
|
|
* @param {number} scale - the scale of the tile layer
|
|
* @param {number} cols - the number of columns of the layer
|
|
* @param {number} rows - the number of rows of the layer
|
|
* @param {number} width - the width of the layer in pixel
|
|
* @param {number} height - the height of the layer in pixel
|
|
* @param {number} tileSize - the size of a single tile in pixel
|
|
* @param {number} overlap - the overlap of the tiles in pixel
|
|
* @param {number} fadeInTime - time needed to fade in tiles if TweenLite is set
|
|
**/
|
|
class Tiles extends PIXI.Container {
|
|
constructor(level, view, scale, cols, rows, width, height, tileSize, overlap, fadeInTime = 0.33) {
|
|
super();
|
|
this.debug = false;
|
|
this.showGrid = false;
|
|
this.view = view;
|
|
this.level = level;
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
this.pixelWidth = width;
|
|
this.pixelHeight = height;
|
|
this.tileSize = tileSize;
|
|
this.overlap = overlap;
|
|
this.needed = new Map(); // url as key, [col, row] as value
|
|
this.requested = new Set();
|
|
this.available = new Map();
|
|
this.scale.set(scale, scale);
|
|
this.tileScale = scale;
|
|
this.fadeInTime = fadeInTime;
|
|
this.keep = false;
|
|
if (this.view.useWorker && view.info.compression && view.info.compression.length > 0) {
|
|
this.loader = new WorkerTileLoader(this, this.view.useWorker);
|
|
} else {
|
|
this.loader = new PIXITileLoader(this, view.info.compression);
|
|
}
|
|
this.interactive = false;
|
|
this._highlight = null;
|
|
|
|
this._info = null;
|
|
|
|
this._centerPoint = null;
|
|
this._boundsRect = null;
|
|
|
|
this.infoColor = Colors.random();
|
|
this.pprint();
|
|
this.destroyed = false;
|
|
}
|
|
|
|
/** Tests whether all tiles are loaded. **/
|
|
isComplete() {
|
|
return this.cols * this.rows === this.children.length
|
|
}
|
|
|
|
/** Returns the highligh graphics layer for debugging purposes.
|
|
**/
|
|
get highlight() {
|
|
if (this._highlight == null) {
|
|
let graphics = new PIXI.Graphics();
|
|
graphics.beginFill(0xffff00, 0.1);
|
|
graphics.lineStyle(2, 0xffff00);
|
|
graphics.drawRect(1, 1, this.tileSize - 2, this.tileSize - 2);
|
|
graphics.endFill();
|
|
graphics.interactive = false;
|
|
this._highlight = graphics;
|
|
}
|
|
return this._highlight
|
|
}
|
|
|
|
/** Returns the highligh graphics layer for debugging purposes.
|
|
**/
|
|
get info() {
|
|
if (this._info == null) {
|
|
let graphics = new PIXI.Graphics();
|
|
graphics.lineStyle(4, 0xff0000);
|
|
graphics.interactive = false;
|
|
this._info = graphics;
|
|
this.addChild(this._info);
|
|
}
|
|
return this._info
|
|
}
|
|
|
|
/** Helper method pretty printing debug information. **/
|
|
pprint() {
|
|
if (this.debug)
|
|
console.log(
|
|
'Tiles level: ' +
|
|
this.level +
|
|
' scale: ' +
|
|
this.scale.x +
|
|
' cols: ' +
|
|
this.cols +
|
|
' rows: ' +
|
|
this.rows +
|
|
' w: ' +
|
|
this.pixelWidth +
|
|
' h: ' +
|
|
this.pixelHeight +
|
|
' tsize:' +
|
|
this.tileSize
|
|
);
|
|
}
|
|
|
|
/** Computes the tile position and obeys the overlap.
|
|
* @param {number} col - The column of the tile
|
|
* @param {number} row - The row of the tile
|
|
* @returns {PIXI.Point} obj
|
|
**/
|
|
tilePosition(col, row) {
|
|
let x = col * this.tileSize;
|
|
let y = row * this.tileSize;
|
|
let overlap = this.overlap;
|
|
if (col != 0) {
|
|
x -= overlap;
|
|
}
|
|
if (row != 0) {
|
|
y -= overlap;
|
|
}
|
|
return new PIXI.Point(x, y)
|
|
}
|
|
|
|
/** Computes the tile size without overlap
|
|
* @param {number} col - The column of the tile
|
|
* @param {number} row - The row of the tile
|
|
* @returns {PIXI.Point} obj
|
|
**/
|
|
tileDimensions(col, row) {
|
|
let w = this.tileSize;
|
|
let h = this.tileSize;
|
|
let pos = this.tilePosition(col, row);
|
|
if (col == this.cols - 1) {
|
|
w = this.pixelWidth - pos.x;
|
|
}
|
|
if (row == this.rows - 1) {
|
|
h = this.pixelHeight - pos.y;
|
|
}
|
|
return new PIXI.Point(w, h)
|
|
}
|
|
|
|
/** Method to support debugging. Highlights the specified tile at col, row **/
|
|
highlightTile(col, row) {
|
|
if (col > -1 && row > -1 && col < this.cols && row < this.rows) {
|
|
let graphics = this.highlight;
|
|
let dim = this.tileDimensions(col, row);
|
|
graphics.position = this.tilePosition(col, row);
|
|
graphics.clear();
|
|
graphics.beginFill(0xff00ff, 0.1);
|
|
graphics.lineStyle(2, 0xffff00);
|
|
graphics.drawRect(1, 1, dim.x - 2, dim.y - 2);
|
|
graphics.endFill();
|
|
this.addChild(this.highlight);
|
|
} else {
|
|
this.removeChild(this.highlight);
|
|
}
|
|
}
|
|
|
|
/** Loads the tiles for the given urls and adds the tiles as sprites.
|
|
* @param {array} urlpos - An array of URL, pos pairs
|
|
* @param {boolean} onlyone - Loads only on tile at a time if true
|
|
**/
|
|
loadTiles(urlpos, onlyone, refCol, refRow) {
|
|
if (this.showGrid) {
|
|
this.highlightTile(refCol, refRow);
|
|
}
|
|
urlpos.forEach(d => {
|
|
let [url, col, row] = d;
|
|
if (this.loader.schedule(url, col, row)) {
|
|
if (onlyone) {
|
|
return this.loader.loadOneTile()
|
|
}
|
|
}
|
|
});
|
|
this.loader.loadAll();
|
|
}
|
|
|
|
/** Private method: add a red border to a tile for debugging purposes. **/
|
|
_addTileBorder(tile, col, row) {
|
|
let dim = this.tileDimensions(col, row);
|
|
let graphics = new PIXI.Graphics();
|
|
graphics.beginFill(0, 0);
|
|
graphics.lineStyle(2, 0xff0000);
|
|
graphics.drawRect(1, 1, dim.x - 2, dim.y - 2);
|
|
graphics.endFill();
|
|
tile.addChild(graphics);
|
|
}
|
|
|
|
/** Adds a tile. **/
|
|
addTile(tile, col, row, url) {
|
|
if (this.available.has(url)) {
|
|
console.warn('Trying to add available tile');
|
|
return
|
|
}
|
|
this.addChildAt(tile, 0);
|
|
this.available.set(url, tile);
|
|
if (this.destroyed) {
|
|
console.warn('Adding to destroyed tiles layer');
|
|
}
|
|
// this._addTileBorder(tile, col, row)
|
|
}
|
|
|
|
/** Called by the loader after each successfull loading of a single tile.
|
|
* Adds the sprite to the tile layer.
|
|
* @param {Object} tile - the loaded tile sprite
|
|
* @param {Number} col - the col position
|
|
* @param {Number} row - the rowposition
|
|
**/
|
|
tileAvailable(tile, col, row, url) {
|
|
let pos = this.tilePosition(col, row);
|
|
if (this.showGrid) {
|
|
this._addTileBorder(tile, col, row);
|
|
}
|
|
tile.position = pos;
|
|
tile.interactive = false;
|
|
if (TweenLite) {
|
|
tile.alpha = 0;
|
|
TweenLite.to(tile, this.fadeInTime, { alpha: this.alpha });
|
|
}
|
|
this.addTile(tile, col, row, url);
|
|
}
|
|
|
|
/** Destroys the tiles layer and destroys the loader. Async load calls are
|
|
* cancelled.
|
|
**/
|
|
destroy() {
|
|
this.destroyed = true;
|
|
this.loader.destroy();
|
|
super.destroy({ children: true }); // Calls destroyChildren
|
|
this.available.clear();
|
|
this.requested.clear();
|
|
this.needed.clear();
|
|
}
|
|
|
|
destroyTile(url, tile) {
|
|
this.loader.unschedule(url);
|
|
this.removeChild(tile);
|
|
tile.destroy();
|
|
this.available.delete(url);
|
|
}
|
|
|
|
destroyTileByUrl(url) {
|
|
if (this.available.has(url)) {
|
|
let tile = this.available.get(url);
|
|
this.destroyTile(url, tile);
|
|
}
|
|
}
|
|
|
|
/* Destroys the tiles which are not with the bounds of the app to free
|
|
* memory.
|
|
**/
|
|
destroyTiles(quadTrees) {
|
|
let count = 0;
|
|
for (let [url, tile] of this.available.entries()) {
|
|
if (!quadTrees.has(url)) {
|
|
this.destroyTile(url, tile);
|
|
count += 1;
|
|
}
|
|
}
|
|
if (count && this.debug) console.log('destroyTiles', this.level, count);
|
|
}
|
|
|
|
destroyUnneededTiles() {
|
|
let count = 0;
|
|
for (let [url, tile] of this.available.entries()) {
|
|
if (!this.needed.has(url)) {
|
|
this.destroyTile(url, tile);
|
|
count += 1;
|
|
}
|
|
}
|
|
if (count && this.debug) console.log('destroyUnneededTiles', this.level, count);
|
|
}
|
|
|
|
highlightInfos() {
|
|
let graphics = this.info;
|
|
let color = this.infoColor;
|
|
graphics.clear();
|
|
graphics.lineStyle(2, color);
|
|
for (let [col, row] of this.needed.values()) {
|
|
let dim = this.tileDimensions(col, row);
|
|
let pos = this.tilePosition(col, row);
|
|
graphics.beginFill(color, 0.2);
|
|
graphics.drawRect(pos.x + 1, pos.y + 1, dim.x - 2, dim.y - 2);
|
|
graphics.endFill();
|
|
}
|
|
let r = this._boundsRect;
|
|
if (r != null) {
|
|
graphics.lineStyle(20, color);
|
|
graphics.drawRect(r.x, r.y, r.width, r.height);
|
|
graphics.moveTo(r.x, r.y);
|
|
graphics.lineTo(r.x + r.width, r.y + r.height);
|
|
|
|
graphics.moveTo(r.x, r.y + r.height);
|
|
graphics.lineTo(r.x + r.width, r.y);
|
|
}
|
|
|
|
let p = this._centerPoint;
|
|
if (p != null) {
|
|
graphics.drawCircle(p.x, p.y, 20);
|
|
}
|
|
}
|
|
|
|
tintTiles(quadTrees) {
|
|
for (let [url, tile] of this.available.entries()) {
|
|
if (!quadTrees.has(url)) tile.tint = 0xff0000;
|
|
}
|
|
}
|
|
|
|
untintTiles() {
|
|
for (let [url, tile] of this.available.entries()) {
|
|
tile.tint = 0xffffff;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isEven(n) {
|
|
return n % 2 == 0
|
|
}
|
|
|
|
/**
|
|
* A utility class that holds information typically provided by DZI files, i.e.
|
|
* height and width of the overall image, overlap, and image type.
|
|
*
|
|
* @constructor
|
|
* @param {obj} attrs - A JSON-Object holding the listed keys and values
|
|
* @example
|
|
* {
|
|
* "tileSize": 1024,
|
|
* "format": "jpeg",
|
|
* "overlap": 1,
|
|
* "height": 4794,
|
|
* "width": 4095,
|
|
* "clip": { "minLevel": 12, "maxLevel": 20, "startCol": 301436, "startRow": 354060 },
|
|
* // optional: minLevel and maxLevel define the level bounds
|
|
* // startCol: first col at maxLevel
|
|
* // startRow: first row at maxLevel
|
|
* "path": "var/Vermeer/Vermeer_files",
|
|
* "type": "dzi", // optional: dzi (default) or map
|
|
* "urlTileTemplate": "{path}/{level}/{column}/{row}.{format}"
|
|
* // optional: {path}/{level}/{column}_{row}.{format} (default) or
|
|
* // a template String with the format of the URL
|
|
* }
|
|
*/
|
|
class DeepZoomInfo {
|
|
constructor(attrs) {
|
|
for (let key in attrs) {
|
|
this[key] = attrs[key];
|
|
}
|
|
this.maxLevel = 0; // The highest level number, typically corresponds to the
|
|
// number in the file system for the folder with tiles
|
|
this.clip = this.clip || null; // e.g. { level: 12, col: 301436, row: 354060 }
|
|
this.type = this.type || 'dzi';
|
|
this.urlTileTemplate = this.urlTileTemplate || '{path}/{level}/{column}_{row}.{format}';
|
|
this.setupDimensions();
|
|
}
|
|
|
|
/* Computes the needed number of layers from the width and height
|
|
* of the image. Note that this includes the level 0, i.e. 0 ... 4
|
|
* means that 5 levels exist.
|
|
**/
|
|
numLevels() {
|
|
let maxDimension = Math.max(this.width, this.height);
|
|
let boundary = this.type === 'dzi' ? 1 : this.tileSize;
|
|
let numLevels = 0;
|
|
while (maxDimension >= boundary) {
|
|
maxDimension /= 2;
|
|
numLevels++;
|
|
}
|
|
return numLevels
|
|
}
|
|
|
|
/** Computes the scale at the given level.
|
|
* @param {number} level - The level of the wanted layer
|
|
* @returns {number} scale
|
|
**/
|
|
getScale(level) {
|
|
let scale = 1;
|
|
if (this.type === 'dzi') {
|
|
scale = Math.pow(0.5, this.maxLevel - level + 1);
|
|
} else {
|
|
scale = Math.pow(0.5, this.maxLevel - level);
|
|
}
|
|
return scale
|
|
}
|
|
|
|
/** Computes the scaled width and height of the given level.
|
|
* @param {number} level - The level of the wanted layer
|
|
* @returns {array} size - The width and height
|
|
**/
|
|
getDimensions(level) {
|
|
let scale = this.getScale(level);
|
|
let w = Math.ceil(this.width * scale);
|
|
let h = Math.ceil(this.height * scale);
|
|
return [w, h]
|
|
}
|
|
|
|
/** Computes the number of cols and rows of tiles.
|
|
* @param {number} level - The level of the wanted layer
|
|
* @returns {array} size - The cols and rows
|
|
**/
|
|
getNumTiles(level) {
|
|
let dim = this.getDimensions(level);
|
|
let cols = Math.ceil(dim[0] / this.tileSize);
|
|
let rows = Math.ceil(dim[1] / this.tileSize);
|
|
if (this.clip) {
|
|
let rest = this.rests[level];
|
|
if (rest) {
|
|
if (rest.restCol) {
|
|
cols += 1;
|
|
}
|
|
if (rest.restRows) {
|
|
rows += 1;
|
|
}
|
|
}
|
|
}
|
|
return [cols, rows]
|
|
}
|
|
|
|
setupDimensions(loadBaseImage = false) {
|
|
/** Setup instance variables and load the base image, i.e. the largest
|
|
image that can be represented as a single tile.
|
|
@private
|
|
**/
|
|
let ww = this.width;
|
|
let hh = this.height;
|
|
let scale = 1.0;
|
|
let level = 0;
|
|
let single = 0;
|
|
const tsize = this.tileSize;
|
|
|
|
if (this.clip) {
|
|
this.baseLevel = this.clip.minLevel;
|
|
this.maxLevel = this.clip.maxLevel;
|
|
this.baseImage = null;
|
|
this.size = this.getDimensions(this.baseLevel);
|
|
this.offsets = {};
|
|
this.rests = {};
|
|
let startCol = this.clip.startCol;
|
|
let startRow = this.clip.startRow;
|
|
let floatStartCol = startCol;
|
|
let floatStartRow = startRow;
|
|
for (let i = this.maxLevel; i >= this.baseLevel; i--) {
|
|
this.offsets[i] = { startCol, startRow };
|
|
let restCol = floatStartCol % 1;
|
|
let restRow = floatStartRow % 1;
|
|
this.rests[i] = { restCol, restRow };
|
|
startCol = Math.floor(startCol / 2);
|
|
startRow = Math.floor(startRow / 2);
|
|
floatStartCol /= 2;
|
|
floatStartRow /= 2;
|
|
}
|
|
} else {
|
|
const boundary = this.type === 'dzi' ? 1.0 : tsize;
|
|
while (ww > boundary && hh > boundary) {
|
|
if (ww >= tsize && hh >= tsize) {
|
|
single += 1;
|
|
}
|
|
scale = scale / 2.0;
|
|
ww = Math.ceil(this.width * scale);
|
|
hh = Math.ceil(this.height * scale);
|
|
level += 1;
|
|
}
|
|
this.baseLevel = level - single;
|
|
this.maxLevel = this.numLevels() - 1;
|
|
this.baseURL = this.urlForTile(this.baseLevel, 0, 0, false);
|
|
|
|
if (loadBaseImage) {
|
|
this.imageForURL(this.baseURL, e => {
|
|
this.size = [e.target.naturalWidth, e.target.naturalHeight];
|
|
this.baseImage = e.target;
|
|
});
|
|
} else {
|
|
this.baseImage = null;
|
|
this.size = this.getDimensions(this.baseLevel);
|
|
}
|
|
}
|
|
}
|
|
|
|
get maxLoadableLevel() {
|
|
if (this.clip) {
|
|
return this.maxLevel
|
|
}
|
|
return this.type === 'dzi' ? this.maxLevel : this.maxLevel
|
|
}
|
|
|
|
/** Computes the url for the given level, column and and row.
|
|
* @param {number} level - The level of the wanted layer
|
|
* @param {number} column - The column of the tile
|
|
* @param {number} row - The row of the tile
|
|
* @returns {string} url
|
|
**/
|
|
urlForTile(level, column, row, compressed = true) {
|
|
let format = this.format;
|
|
if (compressed && this.compression) {
|
|
let supported = Capabilities.isIOS ? 'pvr' : 'dds';
|
|
if (this.compression.indexOf(supported) >= 0) {
|
|
format = supported;
|
|
}
|
|
}
|
|
if (this.clip) {
|
|
let offset = this.offsets[level];
|
|
if (offset) {
|
|
let { startCol, startRow } = offset;
|
|
column += startCol;
|
|
row += startRow;
|
|
}
|
|
}
|
|
let url = this.urlTileTemplate
|
|
.replace(/\{path\}/g, this.path)
|
|
.replace(/\{level\}/g, level)
|
|
.replace(/\{row\}/g, row)
|
|
.replace(/\{column\}/g, column)
|
|
.replace(/\{format\}/g, format);
|
|
return url
|
|
}
|
|
|
|
/** Loads the image for the given url and executes a callback function
|
|
on completion.
|
|
* @param {string} url - The url of the tile
|
|
* @param {function} complete - The callback function
|
|
* @returns {Image} obj
|
|
**/
|
|
imageForURL(url, complete) {
|
|
let img = new Image();
|
|
img.onload = complete.bind(this);
|
|
img.src = url;
|
|
return img
|
|
}
|
|
|
|
/** Computes the columns and rows as well as scaled width and height.
|
|
* @param {number} level - The level of the wanted layer
|
|
* @returns {array} [cols, rows, width, height]
|
|
**/
|
|
dimensions(level) {
|
|
let dim = this.getDimensions(level);
|
|
let tiles = this.getNumTiles(level);
|
|
return [tiles[0], tiles[1], dim[0], dim[1]]
|
|
}
|
|
|
|
test() {
|
|
//console.log("w=" + this.width + " h=" + this.height + " maxlevel=" + this.maxLevel + " base=" + this.baseLevel)
|
|
for (let i = 0; i <= this.maxLevel; i++) {
|
|
console.log(' ' + i + ' -> ' + this.getScale(i) + ' [' + this.dimensions(i) + ']');
|
|
}
|
|
console.log(this.urlForTile(this.baseLevel, 0, 0));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A utility class that describes a quad tree of tiles. Each tile on a given
|
|
* level has up to four corresponding tiles on the next level. A TileQuadNode
|
|
* uses the attributes nw (i.e. northwest), ne, sw, se to link to the
|
|
* quad nodes on the next level. The previous attributes links to the quad
|
|
* one level below that holds the given quad as nw, ne, sw, or se.
|
|
* We use this node class because we need a representation of tiles that are
|
|
* needed but not loaded yet to compute tiles which can be abandoned to reduce
|
|
* the memory pressure.
|
|
*
|
|
* @constructor
|
|
* @param {level} Number - The level the quad node belongs to
|
|
* @param {col} Number - The col of the quad
|
|
* @param {row} Number - The level the quad node belongs to
|
|
* @param {url} String - The level the quad node belongs to
|
|
*/
|
|
class TileQuadNode {
|
|
constructor(level, col, row, url) {
|
|
this.level = level;
|
|
this.col = col;
|
|
this.row = row;
|
|
this.url = url;
|
|
this.nw = null;
|
|
this.ne = null;
|
|
this.sw = null;
|
|
this.se = null;
|
|
this.previous = null;
|
|
}
|
|
|
|
/** Return True if this node has no successors and can be used as
|
|
an indicator of tiles to free.
|
|
**/
|
|
noQuads() {
|
|
if (this.previous === null) return false
|
|
return this.nw === null && this.ne === null && this.sw === null && this.se === null
|
|
}
|
|
|
|
/** Unlink the given quad node
|
|
|
|
* @param {node} TileQuadNode - The TileQuadNode to remove
|
|
**/
|
|
unlink(node) {
|
|
if (this.nw === node) this.nw = null;
|
|
if (this.ne === node) this.ne = null;
|
|
if (this.sw === node) this.sw = null;
|
|
if (this.se === node) this.se = null;
|
|
}
|
|
|
|
/** Link this quad node to the given previous node. Use the north
|
|
* and west flags to address nw, ne, sw, and se.
|
|
|
|
* @param {node} TileQuadNode - The TileQuadNode to remove
|
|
* @param {north} Boolean - Link to north (true) or south (false)
|
|
* @param {west} Boolean - Link to west (true) or east (false)
|
|
**/
|
|
link(north, west, previous) {
|
|
this.previous = previous;
|
|
if (north) {
|
|
if (west) {
|
|
previous.nw = this;
|
|
} else {
|
|
previous.ne = this;
|
|
}
|
|
} else {
|
|
if (west) {
|
|
previous.sw = this;
|
|
} else {
|
|
previous.se = this;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The main class of a deeply zoomable image that is represented by a hierarchy
|
|
* of tile layers for each zoom level. This gives the user the impression that
|
|
* even huge pictures (up to gigapixel-images) can be zoomed instantaneously,
|
|
* since the tiles at smaller levels are scaled immediately and overloaded by
|
|
* more detailed tiles at the larger level as fast as possible.
|
|
|
|
* @constructor
|
|
* @param {DeepZoomInfo} deepZoomInfo - Information extracted from a JSON-Object
|
|
*/
|
|
class DeepZoomImage extends PIXI.Container {
|
|
constructor(
|
|
deepZoomInfo,
|
|
{
|
|
debug = false,
|
|
shadow = false,
|
|
center = false,
|
|
world = null, // Defines the world bounds the images lives in
|
|
highResolution = true,
|
|
autoLoadTiles = true,
|
|
useWorker = '',
|
|
minimumLevel = 0,
|
|
alpha = 1,
|
|
app = window.app
|
|
} = {}
|
|
) {
|
|
super();
|
|
this.app = app;
|
|
this.debug = debug;
|
|
this.shadow = shadow;
|
|
this.world = world;
|
|
this.useWorker = useWorker;
|
|
this.resolution = highResolution ? Math.round(window.devicePixelRatio) : 1;
|
|
this.alpha = alpha;
|
|
this.fastLoads = 0;
|
|
this.active = true;
|
|
this.autoLoadTiles = autoLoadTiles;
|
|
this.minimumLevel = minimumLevel;
|
|
this.quadTrees = new Map(); // url as keys, TileQuadNodes as values
|
|
this.setup(deepZoomInfo, center);
|
|
}
|
|
|
|
get point() {
|
|
if (this._point == null) {
|
|
let graphics = new PIXI.Graphics();
|
|
graphics.lineStyle(2, 0x00ff00);
|
|
graphics.drawCircle(0, 0, 2);
|
|
graphics.interactive = false;
|
|
this._point = graphics;
|
|
}
|
|
return this._point
|
|
}
|
|
|
|
/** Reads the DeepZoomInfo object and initializes all tile layers.
|
|
* Called by the constructor.
|
|
* Creates the sprite for the loaded texture and add the sprite to the tile
|
|
* layer.
|
|
* @param {Object} deepZoomInfo - the DeepZoomInfo instance
|
|
* @param {boolean} center - If true ensures that the pivot is set to the center
|
|
**/
|
|
setup(deepZoomInfo, center) {
|
|
this.info = deepZoomInfo;
|
|
this.interactive = true;
|
|
this.tileLayers = {};
|
|
|
|
this._foreground = null;
|
|
this.tileContainer = new PIXI.Container();
|
|
this.tileContainer.interactive = false;
|
|
|
|
let [w, h] = this.baseSize;
|
|
if (this.shadow) {
|
|
this.filters = [new PIXI.filters.DropShadowFilter({ rotation: 45, distance: 3 })];
|
|
}
|
|
this.addChild(this.tileContainer);
|
|
|
|
if (deepZoomInfo.clip) {
|
|
let mask = new PIXI.Graphics();
|
|
mask.alpha = 0.0000001;
|
|
mask.beginFill(0xff0000, 1);
|
|
mask.drawRect(0, 0, w, h);
|
|
mask.endFill();
|
|
this.mask = mask;
|
|
this.addChild(mask);
|
|
this.minimumLevel = deepZoomInfo.baseLevel;
|
|
}
|
|
this.currentLevel = Math.max(this.minimumLevel, deepZoomInfo.baseLevel);
|
|
//console.log("autoLoadTiles", this.autoLoadTiles)
|
|
if (this.autoLoadTiles) {
|
|
this.setupTiles(center);
|
|
}
|
|
}
|
|
|
|
/** Default setup method for tiles. Loads all tiles of the current level.
|
|
Can be overwritten in subclasses.
|
|
@param {boolean} center - If true ensures that the pivot is set to the center
|
|
**/
|
|
setupTiles(center = false) {
|
|
// First load background tile
|
|
let tiles = this.ensureAllTiles(this.currentLevel);
|
|
if (center) {
|
|
this.pivot.set(w / 2, h / 2);
|
|
}
|
|
let scaleLevel = this.levelForScale(1);
|
|
this.ensureAllTiles(scaleLevel);
|
|
}
|
|
|
|
removeTileQuadNode(level, col, row, url) {
|
|
if (this.quadTrees.has(url)) {
|
|
let quad = this.quadTrees.get(url);
|
|
this.tileQuadRemoved(quad);
|
|
this.quadTrees.delete(url);
|
|
}
|
|
}
|
|
|
|
addTileQuadNode(level, col, row, url) {
|
|
if (this.quadTrees.has(url)) return this.quadTrees.get(url)
|
|
let quad = new TileQuadNode(level, col, row, url);
|
|
this.quadTrees.set(url, quad);
|
|
this.tileQuadAdded(quad);
|
|
return quad
|
|
}
|
|
|
|
tileQuadRemoved(quad) {
|
|
let below = quad.previous;
|
|
// if (this.debug) console.log("tileQuadRemoved", quad)
|
|
if (below) {
|
|
below.unlink(quad);
|
|
if (below.noQuads()) {
|
|
if (this.debug) console.log('Removed tile below');
|
|
let levelBelow = quad.level - 1;
|
|
if (levelBelow < this.minimumLevel) return
|
|
let c = Math.floor(quad.col / 2);
|
|
let r = Math.floor(quad.row / 2);
|
|
let urlBelow = this.info.urlForTile(levelBelow, c, r);
|
|
if (this.quadTrees.has(urlBelow)) {
|
|
this.removeTileQuadNode(levelBelow, c, r, urlBelow);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tileQuadAdded(quad) {
|
|
let levelBelow = quad.level - 1;
|
|
if (levelBelow < this.minimumLevel) return
|
|
//if (this.debug) console.log("tileQuadAdded", quad)
|
|
let c = Math.floor(quad.col / 2);
|
|
let r = Math.floor(quad.row / 2);
|
|
let urlBelow = this.info.urlForTile(levelBelow, c, r);
|
|
let below = null;
|
|
if (!this.quadTrees.has(urlBelow)) {
|
|
below = this.addTileQuadNode(levelBelow, c, r, urlBelow);
|
|
quad.link(isEven(quad.row), isEven(quad.col), below);
|
|
}
|
|
}
|
|
|
|
/** Returns the tile layer level that corresponds to the given scale.
|
|
* @param {number} scale - the scale factor
|
|
**/
|
|
levelForScale(scale) {
|
|
let level = Math.round(Math.log2(scale * this.resolution)); // Math.floor(Math.log2(event.scale))+1
|
|
let newLevel = this.info.baseLevel + Math.max(level, 0);
|
|
return Math.min(newLevel, this.info.maxLoadableLevel)
|
|
}
|
|
|
|
/** Returns the tile layer level that corresponds to the given scale.
|
|
* @param {number} scale - the scale factor
|
|
**/
|
|
levelAndAlphaForScale(scale) {
|
|
let value = Math.log2(scale * this.resolution);
|
|
let level = Math.round(value);
|
|
let newLevel = this.info.baseLevel + Math.max(level, 0);
|
|
|
|
return { level: Math.min(newLevel, this.info.maxLoadableLevel), alpha: value - level }
|
|
}
|
|
|
|
/** Adds a tile layer to the DeepZoomImage.
|
|
* @param {string} key - the access key
|
|
* @param {Tiles} tiles - the tile layer object
|
|
**/
|
|
addTiles(key, tiles) {
|
|
if (key in this.tileLayers) {
|
|
console.warn('Tiles already availabl', key);
|
|
}
|
|
this.tileContainer.addChild(tiles);
|
|
this.tileLayers[key] = tiles;
|
|
}
|
|
|
|
destroyTiles(key) {
|
|
let tiles = this.tileLayers[key];
|
|
this.tileContainer.removeChild(tiles);
|
|
tiles.destroy();
|
|
delete this.tileLayers[key];
|
|
}
|
|
|
|
/** Getter for PIXI.Container foreground layer.
|
|
* Adds a PIXI.Container if necessary.
|
|
**/
|
|
get foreground() {
|
|
if (this._foreground == null) {
|
|
this._foreground = new PIXI.Container();
|
|
this.addChild(this._foreground);
|
|
}
|
|
return this._foreground
|
|
}
|
|
|
|
/** Getter for the DeepZoomInfo base level size.
|
|
**/
|
|
get baseSize() {
|
|
return this.info.getDimensions(this.info.baseLevel)
|
|
}
|
|
|
|
/** Getter for the current scaled size in pixels.
|
|
**/
|
|
get pixelSize() {
|
|
let [w, h] = this.baseSize;
|
|
return [w * this.scale.x, h * this.scale.y]
|
|
}
|
|
|
|
/** Getter for the max scale factor.
|
|
**/
|
|
get maxScale() {
|
|
let delta = this.info.maxLevel - this.info.baseLevel;
|
|
return (Math.pow(2, delta) / this.resolution) * 2
|
|
}
|
|
|
|
/** Getter for the current width.
|
|
**/
|
|
get width() {
|
|
return this.pixelSize[0]
|
|
}
|
|
|
|
/** Getter for the current height.
|
|
**/
|
|
get height() {
|
|
return this.pixelSize[1]
|
|
}
|
|
|
|
/* Overrides PIXI.Container.hitArea()
|
|
* Allows to optimize the hit testing. Container with hit areas are directly
|
|
* hit tested without consideration of children.
|
|
*/
|
|
get hitArea() {
|
|
// Defining the hitArea resulted hitting the scatter in masked area
|
|
// when a mask was used (@Tüsch[submaps]). Removing the hitArea() altogether
|
|
// broke the interaction in other projects (@googleart).
|
|
// Fix: When masked, the hitArea is ignored by returning null.
|
|
// TODO: test if childs are hittested, without setting interactiveChildren.
|
|
// Opel, 03-05-2018
|
|
if (this.mask) {
|
|
return null
|
|
}
|
|
return this
|
|
}
|
|
|
|
/* Overrides PIXI.Container.contains()
|
|
* Allows to optimize the hit testing.
|
|
*/
|
|
contains(x, y) {
|
|
let [w, h] = this.baseSize;
|
|
return x >= 0 && x <= w && y >= 0 && y <= h
|
|
}
|
|
|
|
/** Overrides PIXI.Container._calculateBounds()
|
|
* Only considers the base size and reduces the calculation to a single
|
|
* rect.
|
|
*/
|
|
_calculateBounds() {
|
|
let [w, h] = this.baseSize;
|
|
this._bounds.addFrame(this.transform, 0, 0, w, h);
|
|
}
|
|
|
|
/** Overrides PIXI.Container.calculateBounds()
|
|
* Skips the children and only considers the deep zoom base size. Calls
|
|
* the also overwritten _calculateBounds method.
|
|
*/
|
|
calculateBounds() {
|
|
this._bounds.clear();
|
|
this._calculateBounds();
|
|
this._lastBoundsID = this._boundsID;
|
|
}
|
|
|
|
/** Returns a single sprite that can be used a thumbnail representation of
|
|
* large images.
|
|
* @return {Sprite} sprite - A sprite with a single tile texture
|
|
*/
|
|
thumbnail() {
|
|
return new PIXI.Sprite.from(this.info.baseURL)
|
|
}
|
|
|
|
/** Returns a list of all tiles of a given level.
|
|
* @param {Tiles} tiles - the grid of tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
* @return {Array[]} - An array of [url, col, row] arrays
|
|
**/
|
|
allTiles(tiles, level) {
|
|
let result = [];
|
|
for (let col = 0; col < tiles.cols; col++) {
|
|
for (let row = 0; row < tiles.rows; row++) {
|
|
let url = this.info.urlForTile(level, col, row);
|
|
result.push([url, col, row]);
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
worldBounds() {
|
|
let viewBounds = this.app.scene.bounds || this.app.scene.getBounds();
|
|
// Using getBounds extends visible scope after loading tiles and leads
|
|
// to excessive loading. So we prefer bounds over getBounds()
|
|
if (this.world != null) {
|
|
let bounds = this.world.bounds;
|
|
let x = Math.max(-bounds.width, bounds.x);
|
|
let y = Math.max(-bounds.height, bounds.y);
|
|
let width = Math.min(viewBounds.width, bounds.width);
|
|
let height = Math.min(viewBounds.height, bounds.height);
|
|
//console.log("worldBounds new", { x, y, width, height })
|
|
return { x, y, width, height }
|
|
}
|
|
//console.log("worldBounds old", viewBounds)
|
|
return viewBounds
|
|
}
|
|
|
|
/** Loads all tiles that are needed to fill the app bounds.
|
|
* @param {Tiles} tiles - the grid of tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
* @param {boolean} debug
|
|
* @return {Array[]} - An array of [url, col, row] arrays
|
|
*/
|
|
neededTiles(tiles, level, debug = false) {
|
|
let needed = [];
|
|
let tsize = tiles.tileSize;
|
|
let worldBounds = this.worldBounds();
|
|
let maxWidth = worldBounds.width;
|
|
let maxHeight = worldBounds.height;
|
|
|
|
let pointInWindow = new PIXI.Point();
|
|
let worldTopLeft = new PIXI.Point(worldBounds.x, worldBounds.y);
|
|
let worldBottomRight = new PIXI.Point(worldBounds.x + maxWidth, worldBounds.y + maxHeight);
|
|
let worldCenter = new PIXI.Point(worldBounds.x + maxWidth / 2, worldBounds.y + maxHeight / 2);
|
|
let tilesCenter = tiles.toLocal(worldCenter);
|
|
|
|
let topLeft = tiles.toLocal(worldTopLeft);
|
|
let bottomRight = tiles.toLocal(worldBottomRight);
|
|
tiles._centerPoint = tilesCenter;
|
|
let bounds = new PIXI.Rectangle(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
|
|
|
|
tiles._boundsRect = bounds;
|
|
|
|
/* UO: we need a toLocal call here since the transform may need an update
|
|
which is guaranteed by the toLocal method. */
|
|
let centerCol = Math.floor(tilesCenter.x / tsize);
|
|
let centerRow = Math.floor(tilesCenter.y / tsize);
|
|
|
|
// Expand because we want to test for included centers
|
|
bounds.x -= tsize / 2;
|
|
bounds.y -= tsize / 2;
|
|
bounds.width += tsize;
|
|
bounds.height += tsize;
|
|
|
|
try {
|
|
let maxTilesWidth = Math.ceil(maxWidth / tsize);
|
|
let maxTilesHeight = Math.ceil(maxHeight / tsize);
|
|
|
|
maxTilesWidth += 2;
|
|
maxTilesHeight += 2;
|
|
|
|
let startCol = Math.max(0, centerCol - maxTilesWidth);
|
|
let endCol = Math.min(tiles.cols, centerCol + maxTilesWidth);
|
|
|
|
let startRow = Math.max(0, centerRow - maxTilesHeight);
|
|
let endRow = Math.min(tiles.rows, centerRow + maxTilesHeight);
|
|
|
|
for (let col = startCol; col < endCol; col++) {
|
|
let cx = (col + 0.5) * tsize;
|
|
for (let row = startRow; row < endRow; row++) {
|
|
let cy = (row + 0.5) * tsize;
|
|
let tileCenter = new PIXI.Point(cx, cy);
|
|
if (bounds.contains(tileCenter.x, tileCenter.y)) {
|
|
let url = this.info.urlForTile(level, col, row);
|
|
needed.push([url, col, row]);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(error.message);
|
|
}
|
|
return { centerCol, centerRow, needed }
|
|
}
|
|
|
|
/** Returns all changed tiles for a given level.
|
|
* @param {Tiles} tiles - the grid of tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
* @return {object} - An object with the keys added and removed which values are [url, col, row] arrays
|
|
*/
|
|
changedTiles(tiles, level) {
|
|
if (this.debug) console.time('changedTiles');
|
|
let changed = { added: [], removed: [] };
|
|
let newNeeded = new Map();
|
|
let { centerCol, centerRow, needed } = this.neededTiles(tiles, level);
|
|
needed.forEach(d => {
|
|
let [url, col, row] = d;
|
|
newNeeded.set(url, [col, row]);
|
|
if (!tiles.requested.has(url)) {
|
|
changed.added.push(d);
|
|
}
|
|
});
|
|
for (let url of tiles.needed.keys()) {
|
|
if (!newNeeded.has(url)) {
|
|
let [col, row] = tiles.needed.get(url);
|
|
changed.removed.push([url, col, row]);
|
|
}
|
|
}
|
|
tiles.needed = newNeeded;
|
|
if (this.debug) console.log(newNeeded);
|
|
if (this.debug) console.timeEnd('changedTiles');
|
|
return { centerCol, centerRow, changed }
|
|
}
|
|
|
|
/** Populates all tiles for a given level.
|
|
* @param {Tiles} tiles - the grid of tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
*/
|
|
populateAllTiles(tiles, level) {
|
|
let all = this.allTiles(tiles, level);
|
|
for (let [url, col, row] of all) {
|
|
this.addTileQuadNode(level, col, row, url);
|
|
}
|
|
tiles.loadTiles(all, false, 0, 0);
|
|
}
|
|
|
|
/** Loads all tiles that are needed to fill the browser window.
|
|
* If the optional about parameter is provided (as a point with col as x,
|
|
* and row as y attr) the tiles are sorted by the distance to this point.
|
|
*
|
|
* @param {Tiles} tiles - the grid of tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
* Optional parameter:
|
|
* @param {boolean} onlyone - if true only one tile is loaded
|
|
* @param {PIXI.Point} about - point of interaction
|
|
*/
|
|
populateTiles(tiles, level, { onlyone = false, about = null } = {}) {
|
|
if (tiles.isComplete()) return
|
|
let referenceCol = -1;
|
|
let referenceRow = -1;
|
|
let { centerCol, centerRow, changed } = this.changedTiles(tiles, level);
|
|
if (about != null) {
|
|
// We want to load tiles in the focus of the user first, therefore
|
|
// we sort according to the distance of the focus of interaction
|
|
let refPoint = this.toLocal(about);
|
|
let scaledTileSize = tiles.tileSize * tiles.tileScale;
|
|
referenceCol = Math.floor(refPoint.x / scaledTileSize);
|
|
referenceRow = Math.floor(refPoint.y / scaledTileSize);
|
|
} else {
|
|
referenceCol = centerCol;
|
|
referenceRow = centerRow;
|
|
}
|
|
referenceCol = centerCol;
|
|
referenceRow = centerRow;
|
|
|
|
let removed = changed.removed;
|
|
for (let [url, col, row] of removed) {
|
|
this.removeTileQuadNode(level, col, row, url);
|
|
}
|
|
let added = changed.added;
|
|
if (added.length == 0) return
|
|
for (let [url, col, row] of added) {
|
|
this.addTileQuadNode(level, col, row, url);
|
|
}
|
|
let ref = new PIXI.Point(referenceCol, referenceRow);
|
|
// Note: The array must be sorted in a way that the nearest tiles
|
|
// are at the end of the array since the load queue uses Array.push
|
|
// Array.pop
|
|
added.sort((a, b) => {
|
|
let aa = new PIXI.Point(a[1], a[2]);
|
|
let bb = new PIXI.Point(b[1], b[2]);
|
|
let da = Points.distance(aa, ref);
|
|
let db = Points.distance(bb, ref);
|
|
return db - da
|
|
});
|
|
tiles.loadTiles(added, onlyone, referenceCol, referenceRow);
|
|
}
|
|
|
|
/** Private method: creates all tiles for a given level.
|
|
* @param {number} level - The zoom level of the grid
|
|
* @return {Tiles} - tiles
|
|
*/
|
|
_createTiles(key, level) {
|
|
let [cols, rows, w, h] = this.info.dimensions(level);
|
|
let increasedLevels = level - this.info.baseLevel;
|
|
let invScale = Math.pow(0.5, increasedLevels);
|
|
let tiles = new Tiles(level, this, invScale, cols, rows, w, h, this.info.tileSize, this.info.overlap);
|
|
this.addTiles(key, tiles);
|
|
if (this.info.clip) {
|
|
let rest = this.info.rests[level];
|
|
if (rest) {
|
|
let x = rest.restCol * this.info.tileSize * invScale;
|
|
let y = rest.restRow * this.info.tileSize * invScale;
|
|
tiles.x = -x;
|
|
tiles.y = -y;
|
|
}
|
|
}
|
|
return tiles
|
|
}
|
|
|
|
/** Ensures that all needed tiles of a given level are loaded. Creates
|
|
* a new Tiles layer if necessary
|
|
* @param {number} level - The zoom level of the grid
|
|
* @return {Tiles} tiles
|
|
*/
|
|
ensureTiles(level, about) {
|
|
let key = level.toString();
|
|
if (key in this.tileLayers) {
|
|
let tiles = this.tileLayers[key];
|
|
this.populateTiles(tiles, level, { about: about });
|
|
return tiles
|
|
}
|
|
let tiles = this._createTiles(key, level);
|
|
this.populateTiles(tiles, level, { about: about });
|
|
//console.log("ensureTiles", level)
|
|
return tiles
|
|
}
|
|
|
|
untintTiles(level) {
|
|
let key = level.toString();
|
|
if (key in this.tileLayers) {
|
|
let tiles = this.tileLayers[key];
|
|
}
|
|
}
|
|
|
|
/** Ensures that all tiles of a given level are loaded.
|
|
* @param {number} level - The zoom level of the grid
|
|
*/
|
|
ensureAllTiles(level) {
|
|
let key = level.toString();
|
|
if (key in this.tileLayers) {
|
|
let tiles = this.tileLayers[key];
|
|
this.populateAllTiles(tiles, level);
|
|
tiles.keep = true;
|
|
return
|
|
}
|
|
let tiles = this._createTiles(key, level);
|
|
this.populateAllTiles(tiles, level);
|
|
tiles.keep = true;
|
|
return tiles
|
|
}
|
|
|
|
hideTilesAboveLevel(level) {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (tiles.level > level) {
|
|
tiles.visible = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Destroys all tiles above a given level to ensure that the memory can
|
|
* be reused.
|
|
* @param {number} level - The zoom level of the grid
|
|
*/
|
|
destroyTilesAboveLevel(level) {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (tiles.level > level && !tiles.keep) {
|
|
for (let url of tiles.available.keys()) {
|
|
let quad = this.quadTrees.get(url);
|
|
if (quad) this.removeTileQuadNode(quad);
|
|
}
|
|
this.destroyTiles(key);
|
|
}
|
|
});
|
|
}
|
|
|
|
destroyAllTiles() {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
this.destroyTiles(key);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tint tiles in all layers that are no longer needed
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
tintObsoleteTiles() {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
tiles.untintTiles();
|
|
if (!tiles.keep) {
|
|
tiles.tintObsoleteTiles();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Destroy tiles in all layers that are no longer needed
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
destroyUnneededTiles() {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (!tiles.keep) {
|
|
tiles.destroyUnneededTiles();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Destroy tiles in all layers that are no longer needed
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
destroyObsoleteTiles() {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (!tiles.keep) {
|
|
tiles.destroyObsoleteTiles();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Destroy tiles in all layers that are not part of the
|
|
* visible quadTrees
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
destroyTiles() {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (!tiles.keep) {
|
|
tiles.destroyTiles(this.quadTrees);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* Tint all tiles
|
|
* @param {number} level - The zoom level of the grid
|
|
*/
|
|
tintTilesBelowLevel(level) {
|
|
Object.keys(this.tileLayers).forEach(key => {
|
|
let tiles = this.tileLayers[key];
|
|
if (tiles.level < level) {
|
|
tiles.tintTiles(this.quadTrees);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ensure that the given tiles layer is the topmost one and visible.
|
|
* @param {*} tiles
|
|
*/
|
|
bringTilesToFront(tiles) {
|
|
this.tileContainer.addChild(tiles);
|
|
tiles.visible = true;
|
|
}
|
|
|
|
/** A callback function that can be used by a Scatter view to inform
|
|
* the zoomable image that it has been moved, rotated or scaled, and should
|
|
* load tiles accordingly.
|
|
* @param {PIXI.Point} translated - the movement of the scatter
|
|
* @param {number} scale - the zoom factor
|
|
* @param {PIXI.Point} about - the anchor point of the zoom
|
|
* @param {boolean} fast - informs the callback to return as fast as possible,
|
|
* i.e. after loading a single tile
|
|
* @param {boolean} debug - log debug infos
|
|
*/
|
|
transformed(event) {
|
|
if (!this.active) {
|
|
return
|
|
}
|
|
let key = this.currentLevel.toString();
|
|
let currentTiles = this.tileLayers[key];
|
|
if (typeof currentTiles == 'undefined') {
|
|
return
|
|
}
|
|
if (event.fast) {
|
|
this.fastLoads += 1;
|
|
this.populateTiles(currentTiles, this.currentLevel, {
|
|
onlyone: false,
|
|
about: event.about
|
|
});
|
|
if (this.fastLoads == 3) {
|
|
this.fastLoads = 0;
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
if (event.scale == null) {
|
|
this.ensureTiles(this.currentLevel, event.about);
|
|
return
|
|
}
|
|
|
|
let level = this.levelForScale(event.scale);
|
|
let newLevel = Math.max(level, this.minimumLevel);
|
|
if (newLevel != this.currentLevel) {
|
|
if (!currentTiles.keep) {
|
|
currentTiles.loader.cancel();
|
|
}
|
|
this.hideTilesAboveLevel(newLevel);
|
|
currentTiles = this.ensureTiles(newLevel, event.about);
|
|
this.currentLevel = newLevel;
|
|
} else {
|
|
this.ensureTiles(this.currentLevel, event.about);
|
|
}
|
|
this.bringTilesToFront(currentTiles);
|
|
if (this._foreground) {
|
|
this.addChild(this._foreground);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*Activates the textures on the DeepZoomImage.
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
activate() {
|
|
this.active = true;
|
|
this.destroyTilesAboveLevel(this.currentLevel);
|
|
this.ensureTiles(this.currentLevel, null);
|
|
//console.log("Activate Textures!", this.currentLevel)
|
|
}
|
|
|
|
/**
|
|
*Dectivates the textures on the DeepZoomImage.
|
|
*
|
|
* @memberof DeepZoomImage
|
|
*/
|
|
deactivate() {
|
|
this.active = false;
|
|
this.destroyAllTiles();
|
|
this.tileContainer.destroy({ children: true });
|
|
}
|
|
|
|
throwFinished() {
|
|
//console.log("throwFinished")
|
|
let key = this.currentLevel.toString();
|
|
let currentTiles = this.tileLayers[key];
|
|
if (typeof currentTiles == 'undefined') {
|
|
return
|
|
}
|
|
this.ensureTiles(this.currentLevel);
|
|
}
|
|
}
|
|
|
|
let globalScatterLoaderCanvas = null;
|
|
|
|
class ScatterLoader extends CardLoader {
|
|
get scatter() {
|
|
return this.src
|
|
}
|
|
|
|
unscaledSize() {
|
|
let displayObject = this.scatter.displayObject;
|
|
let w = displayObject.width;
|
|
let h = displayObject.height;
|
|
return [w / displayObject.scale.x, h / displayObject.scale.y]
|
|
}
|
|
|
|
scaledSize() {
|
|
let displayObject = this.scatter.displayObject;
|
|
let w = displayObject.width;
|
|
let h = displayObject.height;
|
|
return [w, h]
|
|
}
|
|
|
|
cloneScatterImage() {
|
|
let w = this.scatter.width;
|
|
let h = this.scatter.height;
|
|
let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
|
|
let isDeepZoom = this.scatter.displayObject instanceof DeepZoomImage;
|
|
let resolution = app.renderer.resolution;
|
|
if (isSprite) {
|
|
w = this.scatter.displayObject.texture.width;
|
|
h = this.scatter.displayObject.texture.height;
|
|
} else if (isDeepZoom) {
|
|
let [ww, hh] = this.scatter.displayObject.baseSize;
|
|
w = ww;
|
|
h = hh;
|
|
}
|
|
if (globalScatterLoaderCanvas === null) {
|
|
globalScatterLoaderCanvas = document.createElement('canvas');
|
|
}
|
|
let canvas = globalScatterLoaderCanvas;
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
let renderer = new PIXI.Renderer({
|
|
width: w,
|
|
height: h,
|
|
view: canvas,
|
|
resolution: resolution
|
|
});
|
|
|
|
let displayObject = this.scatter.displayObject;
|
|
let x = displayObject.x;
|
|
let y = displayObject.y;
|
|
let rot = displayObject.rotation;
|
|
let sx = displayObject.scale.x;
|
|
let sy = displayObject.scale.y;
|
|
displayObject.rotation = 0;
|
|
// The Safari WebGLRenderer wants everything flipped
|
|
// See https://github.com/pixijs/pixi.js/issues/2283
|
|
displayObject.x = 0;
|
|
if (Capabilities.isSafari) {
|
|
displayObject.y = h;
|
|
displayObject.scale.set(1, -1); // sx, -sy)
|
|
} else {
|
|
displayObject.y = 0;
|
|
displayObject.scale.set(1, 1);
|
|
}
|
|
if (isSprite) {
|
|
displayObject.width = w;
|
|
displayObject.height = h;
|
|
}
|
|
renderer.render(displayObject);
|
|
displayObject.rotation = rot;
|
|
displayObject.x = x;
|
|
displayObject.y = y;
|
|
displayObject.scale.set(sx, sy);
|
|
|
|
let url = canvas.toDataURL();
|
|
return [x, y, w, h, url]
|
|
}
|
|
|
|
load(domNode) {
|
|
return new Promise((resolve, reject) => {
|
|
let isImage = domNode instanceof HTMLImageElement;
|
|
let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
|
|
let image = isImage ? domNode : document.createElement('img');
|
|
let [x, y, w, h, cloneURL] = this.cloneScatterImage();
|
|
let [ww, hh] = this.unscaledSize();
|
|
image.onload = e => {
|
|
if (!isImage) domNode.appendChild(image);
|
|
this.x = x;
|
|
this.y = y;
|
|
this.wantedWidth = ww;
|
|
this.wantedHeight = hh;
|
|
this.scale = 1;
|
|
this.rotation = this.scatter.rotation;
|
|
resolve(this);
|
|
};
|
|
image.onerror = e => {
|
|
reject(this);
|
|
};
|
|
image.src = cloneURL;
|
|
})
|
|
}
|
|
}
|
|
|
|
class FlipEffect {
|
|
constructor(scatter, domScatterContainer, flipTemplate, backLoader) {
|
|
this.flipped = false;
|
|
this.scatter = scatter;
|
|
this.backLoader = backLoader;
|
|
this.scatterLoader = new ScatterLoader(scatter);
|
|
this.domFlip = new DOMFlip(domScatterContainer, flipTemplate, this.scatterLoader, backLoader, {
|
|
onBack: this.backCardClosed.bind(this)
|
|
});
|
|
this.setupInfoButton();
|
|
}
|
|
|
|
startFlip() {
|
|
let center = this.flipCenter();
|
|
let loader = this.backLoader;
|
|
this.domFlip.load().then(domFlip => {
|
|
this.scatter.displayObject.visible = false;
|
|
domFlip.centerAt(center);
|
|
domFlip.zoom(this.scatter.scale);
|
|
let target = this.constraintFlipCenter(center, loader);
|
|
domFlip.start({ targetCenter: target });
|
|
});
|
|
}
|
|
|
|
unscaledSize() {
|
|
return this.scatterLoader.unscaledSize()
|
|
}
|
|
|
|
flipCenter() {
|
|
let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
|
|
let resolution = isSprite ? app.renderer.resolution : 1;
|
|
let center = this.scatter.center;
|
|
let canvas = app.renderer.view;
|
|
let domNode = this.domFlip.domScatterContainer.element;
|
|
let page = window.convertPointFromNodeToPage(canvas, center.x * resolution, center.y * resolution);
|
|
let local = window.convertPointFromPageToNode(domNode, page.x, page.y);
|
|
return local
|
|
}
|
|
|
|
constraintFlipCenter(center, loader) {
|
|
let w = loader.wantedWidth;
|
|
let h = loader.wantedHeight;
|
|
let canvas = app.renderer.view;
|
|
let x = center.x;
|
|
let y = center.y;
|
|
if (x < w / 2) x = w / 2;
|
|
if (y < h / 2) y = h / 2;
|
|
if (x > canvas.width) x = canvas.width - w / 2;
|
|
if (y > canvas.height) y = canvas.height - h / 2;
|
|
return { x, y }
|
|
}
|
|
|
|
setupInfoButton() {
|
|
let iscale = 1.0 / this.scatter.scale;
|
|
this.infoBtn = new PIXI.Graphics();
|
|
this.infoBtn.beginFill(0x333333);
|
|
this.infoBtn.lineStyle(4, 0xffffff);
|
|
this.infoBtn.drawCircle(0, 0, 22);
|
|
this.infoBtn.endFill();
|
|
|
|
this.infoBtn.beginFill(0xffffff);
|
|
this.infoBtn.lineStyle(0);
|
|
this.infoBtn.drawCircle(0, -8, 4);
|
|
this.infoBtn.endFill();
|
|
|
|
this.infoBtn.lineStyle(6, 0xffffff);
|
|
this.infoBtn.moveTo(0, -2);
|
|
this.infoBtn.lineTo(0, 14);
|
|
this.infoBtn.endFill();
|
|
|
|
this.infoBtn.on('click', e => {
|
|
this.infoSelected();
|
|
});
|
|
this.infoBtn.on('tap', e => {
|
|
this.infoSelected();
|
|
});
|
|
|
|
this.infoBtn.interactive = true;
|
|
this.infoBtn.width = 44;
|
|
this.infoBtn.height = 44;
|
|
this.infoBtn.pivot.x = 30;
|
|
this.infoBtn.pivot.y = 30;
|
|
|
|
let displayObject = this.scatter.displayObject;
|
|
let [w, h] = this.unscaledSize();
|
|
this.infoBtn.position = new PIXI.Point(w, h);
|
|
if (displayObject.foreground) {
|
|
this.infoBtn.scale.x = iscale;
|
|
this.infoBtn.scale.y = iscale;
|
|
displayObject.foreground.addChild(this.infoBtn);
|
|
} else {
|
|
displayObject.addChild(this.infoBtn);
|
|
}
|
|
|
|
this.scatter.addTransformEventCallback(e => {
|
|
let displayObject = this.scatter.displayObject;
|
|
if (displayObject.foreground) {
|
|
if (e.scale) {
|
|
let iscale = 1.0 / e.scale;
|
|
this.infoBtn.scale.x = iscale;
|
|
this.infoBtn.scale.y = iscale;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
setupButton(url) {
|
|
let svgImage = new Image();
|
|
let canvas = document.createElement('canvas');
|
|
canvas.width = 88 * 4;
|
|
canvas.height = 44 * 4;
|
|
svgImage.onload = e => {
|
|
let displayObject = this.scatter.displayObject;
|
|
canvas.getContext('2d').drawImage(svgImage, 0, 0, canvas.width, canvas.height);
|
|
let texure = new PIXI.Texture(new PIXI.BaseTexture(canvas));
|
|
this.infoBtn = new PIXI.Sprite(texure);
|
|
this.infoBtn.anchor.set(0.5, 0.5);
|
|
if (displayObject.foreground) {
|
|
displayObject.foreground.addChild(this.infoBtn);
|
|
} else {
|
|
displayObject.addChild(this.infoBtn);
|
|
}
|
|
this.infoBtn.scale.set(0.5, 0.5);
|
|
|
|
let [w, h] = this.unscaledSize();
|
|
this.infoBtn.position = new PIXI.Point(w, h);
|
|
this.infoBtn.interactive = true;
|
|
this.infoBtn.updateTransform();
|
|
this.infoBtn.on('click', e => {
|
|
this.infoSelected();
|
|
});
|
|
this.infoBtn.on('tap', e => {
|
|
this.infoSelected();
|
|
});
|
|
};
|
|
svgImage.src = url;
|
|
}
|
|
|
|
infoSelected() {
|
|
this.startFlip();
|
|
}
|
|
|
|
backSelected() {
|
|
this.domFlip.start();
|
|
}
|
|
|
|
backCardClosed() {
|
|
/*** The flip effect should now be in it's initial state again. All
|
|
memory should be freed. ***/
|
|
let displayObject = this.scatter.displayObject;
|
|
displayObject.visible = true;
|
|
this.domFlip.fadeOutAndRemove();
|
|
this.flipped = false;
|
|
}
|
|
|
|
targetRotation(alpha) {
|
|
let ortho = 90;
|
|
let rest = alpha % ortho;
|
|
let delta = 0.0;
|
|
if (rest > ortho / 2.0) {
|
|
delta = ortho - rest;
|
|
} else {
|
|
delta = -rest;
|
|
}
|
|
return delta
|
|
}
|
|
}
|
|
|
|
/* globals Power2, Sine */
|
|
/*eslint no-console: ["error", { allow: ["log", "info", "error"] }]*/
|
|
|
|
/**
|
|
* Callback for the flippable onStart function.
|
|
*
|
|
* @callback onStartCallback
|
|
* @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the flippable onUpdate function.
|
|
*
|
|
* @callback onUpdateCallback
|
|
* @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the flippable onComplete function.
|
|
*
|
|
* @callback onCompleteCallback
|
|
* @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
|
|
*/
|
|
|
|
/**
|
|
* Class that represents a PixiJS Flippable.
|
|
*
|
|
* @example
|
|
* const front = PIXI.Sprite.from('./assets/front.jpg')
|
|
* const back = PIXI.Sprite.from('./assets/back.jpg')
|
|
* app.scene.addChild(front)
|
|
*
|
|
* // Create the flippable
|
|
* const flippable = new Flippable(front, back, app.renderer)
|
|
*
|
|
* front.interactive = true
|
|
* front.on('click', event => flippable.toggle())
|
|
*
|
|
* @class
|
|
* @extends PIXI.projection.Camera3d
|
|
* @see {@link https://github.com/pixijs/pixi-projection|PixiJS Projection}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/flippable.html|DocTest}
|
|
*/
|
|
class Flippable extends PIXI.projection.Camera3d {
|
|
/**
|
|
* Creates an instance of a Flippable.
|
|
*
|
|
* @constructor
|
|
* @param {PIXI.DisplayObject} front - The object to show initially. Should have been added to the scene.
|
|
* @param {PIXI.DisplayObject} back - The object to show on the backside. Should have not been added to the scene.
|
|
* @param {PIXI.Renderer|PIXI.CanvasRenderer} renderer - The renderer of the application.
|
|
* @param {object} [opts] - An options object which can contain the following properties.
|
|
* @param {number} [opts.duration=1] - The duration of the flip animation in seconds.
|
|
* @param {GSAP.Ease} [opts.ease=Power2.easeOut] - The ease of the flip animation.
|
|
* @param {boolean} [opts.shadow=false] - Should be a shadow been display during the animation?
|
|
* @param {numer} [opts.eulerX=0] - The shift of the x-axis during the animation.
|
|
* @param {numer} [opts.eulerY=0] - The shift of the y-axis during the animation.
|
|
* @param {GSAP.Ease} [opts.eulerEase=Power1.easeOut] - The ease of the shift.
|
|
* @param {boolean} [opts.useBackTransforms=false] - When set to true, the flip animation also animates to the transform parameters of the back-object.
|
|
* @param {GSAP.Ease} [opts.transformEase=Power2.easeOut] - The ease of the transform.
|
|
* @param {numer} [opts.focus=800] - The value of the focus of the 3D camera (see pixi-projection).
|
|
* @param {numer} [opts.near=10] - The near value of the 3D camera (see pixi-projection).
|
|
* @param {numer} [opts.far=10000] - The far value of the 3D camera (see pixi-projection).
|
|
* @param {boolean} [opts.orthographic=false] - Should the flip animation be an orthographic animation?
|
|
* @param {function} [opts.onStart=null] - A callback executed on start of the flip animation.
|
|
* @param {function} [opts.onUpdate=null] - A callback executed on each step of the flip animation.
|
|
* @param {function} [opts.onComplete=null] - A callback executed when the flip animation is finished.
|
|
*/
|
|
constructor(front, back, renderer, opts = {}) {
|
|
super();
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
front,
|
|
back,
|
|
renderer,
|
|
duration: 1,
|
|
ease: Power2.easeOut,
|
|
shadow: false,
|
|
eulerX: 0,
|
|
eulerY: 0,
|
|
eulerEase: Power1.easeOut,
|
|
useBackTransforms: false,
|
|
transformEase: Power2.easeOut,
|
|
focus: 800,
|
|
near: 10,
|
|
far: 10000,
|
|
orthographic: false
|
|
},
|
|
opts
|
|
);
|
|
|
|
// planes
|
|
//--------------------
|
|
this.setPlanes(this.opts.focus, this.opts.near, this.opts.far, this.opts.orthographic);
|
|
|
|
// flipped
|
|
//--------------------
|
|
this._flipped = false;
|
|
|
|
// objects
|
|
//--------------------
|
|
this.objects = {};
|
|
|
|
// setup
|
|
//--------------------
|
|
this.setup();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Flippable} A reference to the flippable for chaining.
|
|
*/
|
|
setup() {
|
|
const scale = 0.5;
|
|
|
|
// filters
|
|
//--------------------
|
|
const blurFilter = new PIXI.filters.BlurFilter();
|
|
blurFilter.blur = 0.2;
|
|
this.objects.blurFilter = blurFilter;
|
|
|
|
// outer
|
|
//--------------------
|
|
const outer = new PIXI.projection.Container3d();
|
|
outer.scale3d.set(scale);
|
|
this.addChild(outer);
|
|
this.objects.outer = outer;
|
|
|
|
// shadow
|
|
//--------------------
|
|
const shadow = new PIXI.projection.Sprite3d(PIXI.Texture.from('../../assets/images/shadow.png'));
|
|
shadow.renderable = false;
|
|
shadow.anchor.set(0.5);
|
|
shadow.scale3d.set(0.98);
|
|
shadow.alpha = 0.7;
|
|
shadow.filters = [blurFilter];
|
|
shadow.visible = this.opts.shadow;
|
|
outer.addChild(shadow);
|
|
this.objects.shadow = shadow;
|
|
|
|
// inner
|
|
//--------------------
|
|
const inner = new PIXI.projection.Container3d();
|
|
inner.euler.y = Math.PI;
|
|
outer.addChild(inner);
|
|
this.objects.inner = inner;
|
|
|
|
// front
|
|
//--------------------
|
|
const front = new PIXI.projection.Sprite3d(PIXI.Texture.EMPTY);
|
|
front.scale.set(-1 / scale, 1 / scale);
|
|
front.renderable = true;
|
|
front.anchor.set(0.5);
|
|
inner.addChild(front);
|
|
this.objects.front = front;
|
|
|
|
// back
|
|
//--------------------
|
|
const back = new PIXI.projection.Sprite3d(PIXI.Texture.EMPTY);
|
|
back.scale.set(1 / scale, 1 / scale);
|
|
back.renderable = false;
|
|
back.anchor.set(0.5);
|
|
inner.addChild(back);
|
|
this.objects.back = back;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the flipped state.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get flipped() {
|
|
return this._flipped
|
|
}
|
|
set flipped(toBack) {
|
|
this._flipped = toBack;
|
|
|
|
// references
|
|
//--------------------
|
|
const front = this.objects.front;
|
|
const back = this.objects.back;
|
|
const inner = this.objects.inner;
|
|
const shadow = this.objects.shadow;
|
|
const blurFilter = this.objects.blurFilter;
|
|
|
|
const half = this.opts.duration / 2;
|
|
const ease = this.opts.eulerEase;
|
|
|
|
const fromObject = toBack ? this.opts.front : this.opts.back;
|
|
const toObject = toBack ? this.opts.back : this.opts.front;
|
|
|
|
// set textures for virtual front and virtual back
|
|
//--------------------
|
|
front.texture = this.generateTexture(this.opts.front);
|
|
back.texture = this.generateTexture(this.opts.back);
|
|
|
|
// switch objects and set params for virtual objects
|
|
//--------------------
|
|
const fromCenter = this.anchorToCenter(fromObject);
|
|
const toCenter = this.anchorToCenter(toObject);
|
|
|
|
// from values
|
|
//--------------------
|
|
this.x = fromCenter.x;
|
|
this.y = fromCenter.y;
|
|
front.width = fromObject.width * 2;
|
|
front.height = fromObject.height * 2;
|
|
back.width = fromObject.width * 2;
|
|
back.height = fromObject.height * 2;
|
|
this.rotation = fromObject.rotation;
|
|
this.skew.x = fromObject.skew.x;
|
|
this.skew.y = fromObject.skew.y;
|
|
|
|
// calculate to values
|
|
//--------------------
|
|
const to = {
|
|
x: this.opts.useBackTransforms ? toCenter.x : fromCenter.x,
|
|
y: this.opts.useBackTransforms ? toCenter.y : fromCenter.y,
|
|
anchorX: this.opts.useBackTransforms ? toObject.x : fromObject.x,
|
|
anchorY: this.opts.useBackTransforms ? toObject.y : fromObject.y,
|
|
width: this.opts.useBackTransforms ? toObject.width * 2 : fromObject.width * 2,
|
|
height: this.opts.useBackTransforms ? toObject.height * 2 : fromObject.height * 2,
|
|
rotation: this.opts.useBackTransforms ? toObject.rotation : fromObject.rotation,
|
|
skewX: this.opts.useBackTransforms ? toObject.skew.x : fromObject.skew.x,
|
|
skewY: this.opts.useBackTransforms ? toObject.skew.y : fromObject.skew.y
|
|
};
|
|
|
|
// set toObject end values
|
|
//--------------------
|
|
toObject.x = to.anchorX;
|
|
toObject.y = to.anchorY;
|
|
toObject.width = to.width / 2;
|
|
toObject.height = to.height / 2;
|
|
toObject.rotation = to.rotation;
|
|
toObject.skew.x = to.skewX;
|
|
toObject.skew.y = to.skewY;
|
|
|
|
// flip
|
|
//--------------------
|
|
TweenLite.to(inner.euler, this.opts.duration, {
|
|
y: toBack ? 0 : Math.PI,
|
|
ease: this.opts.ease,
|
|
onStart: () => {
|
|
this.switchDisplayObject(fromObject, this);
|
|
shadow.renderable = true;
|
|
if (this.opts.onStart) {
|
|
this.opts.onStart(this, this);
|
|
}
|
|
},
|
|
onUpdate: () => {
|
|
this.layout();
|
|
if (this.opts.onUpdate) {
|
|
this.opts.onUpdate(this, this);
|
|
}
|
|
},
|
|
onComplete: () => {
|
|
this.switchDisplayObject(this, toObject);
|
|
shadow.renderable = false;
|
|
if (this.opts.onComplete) {
|
|
this.opts.onComplete(this, this);
|
|
}
|
|
}
|
|
});
|
|
|
|
// x & y
|
|
//--------------------
|
|
TweenLite.to(this, this.opts.duration, {
|
|
x: to.x,
|
|
y: to.y,
|
|
ease: this.opts.transformEase
|
|
});
|
|
|
|
// width & height
|
|
//--------------------
|
|
TweenLite.to([front, back], this.opts.duration, {
|
|
width: to.width,
|
|
height: to.height,
|
|
ease: this.opts.transformEase
|
|
});
|
|
|
|
// rotation
|
|
//--------------------
|
|
TweenLite.to(this, this.opts.duration, {
|
|
directionalRotation: {
|
|
rotation: `${to.rotation}_short`,
|
|
useRadians: true
|
|
},
|
|
ease: this.opts.transformEase
|
|
});
|
|
|
|
// skewX & skewY
|
|
//--------------------
|
|
TweenLite.to(this.skew, this.opts.duration, {
|
|
x: to.skewX,
|
|
y: to.skewY,
|
|
ease: this.opts.transformEase
|
|
});
|
|
|
|
// camera
|
|
//--------------------
|
|
new TimelineMax()
|
|
.to(this.euler, half, {
|
|
x: this.opts.eulerX,
|
|
y: this.opts.eulerY,
|
|
ease
|
|
})
|
|
.to(this.euler, half, { x: 0, y: 0, ease });
|
|
|
|
// shadow
|
|
//--------------------
|
|
new TimelineMax().to(shadow, half, { alpha: 0.3, ease }).to(shadow, half, { alpha: 0.7, ease });
|
|
|
|
// blurfilter
|
|
//--------------------
|
|
new TimelineMax().to(blurFilter, half, { blur: 6, ease }).to(blurFilter, half, { blur: 0.2, ease });
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the camera.
|
|
*
|
|
* @return {Flippable} A reference to the flippable for chaining.
|
|
*/
|
|
layout() {
|
|
const front = this.objects.front;
|
|
const back = this.objects.back;
|
|
const shadow = this.objects.shadow;
|
|
const inner = this.objects.inner;
|
|
|
|
inner.position3d.z = -Math.sin(inner.euler.y) * front.texture.baseTexture.width * 2;
|
|
|
|
//this.objects.shadow.euler = this.objects.inner.euler
|
|
shadow.euler.x = -inner.euler.x;
|
|
shadow.euler.y = -inner.euler.y;
|
|
|
|
if (this.frontSideInFront) {
|
|
front.renderable = true;
|
|
back.renderable = false;
|
|
shadow.width = front.width;
|
|
shadow.height = front.height;
|
|
} else {
|
|
front.renderable = false;
|
|
back.renderable = true;
|
|
shadow.width = back.width;
|
|
shadow.height = back.height;
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Toggles the flippable. Switches the sides.
|
|
*
|
|
* @private
|
|
* @return {Flippable} A reference to the flippable for chaining.
|
|
*/
|
|
toggle() {
|
|
this.flipped = !this.flipped;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets the alignment state. true if the front side is in front, false otherwise.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get frontSideInFront() {
|
|
return !this.objects.inner.isFrontFace()
|
|
}
|
|
|
|
/**
|
|
* Calculates the center point of an PIXI.DisplayObject.
|
|
*
|
|
* @private
|
|
* @param {PIXI.DisplayObject} displayObject - The DisplayObject from which to calculate the center.
|
|
* @return {object} Return an object with x and y.
|
|
*/
|
|
anchorToCenter(displayObject) {
|
|
const bounds = displayObject.getBounds();
|
|
return {
|
|
x: bounds.x + bounds.width / 2,
|
|
y: bounds.y + bounds.height / 2
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @param {PIXI.DisplayObject} displayObject - The DisplayObject from which to generate the texture.
|
|
* @return {PIXI.Texture} The generated PIXI.Texture.
|
|
*/
|
|
generateTexture(displayObject) {
|
|
// renderTexture
|
|
//--------------------
|
|
const renderTexture = PIXI.RenderTexture.create(displayObject.width, displayObject.height);
|
|
|
|
// save position
|
|
const transform = [
|
|
displayObject.x,
|
|
displayObject.y,
|
|
displayObject.scale.x,
|
|
displayObject.scale.y,
|
|
displayObject.rotation,
|
|
displayObject.skew.x,
|
|
displayObject.skew.y,
|
|
displayObject.pivot.x,
|
|
displayObject.pivot.y
|
|
];
|
|
|
|
displayObject.position.set(0, 0);
|
|
displayObject.skew.set(0, 0);
|
|
displayObject.rotation = 0;
|
|
|
|
// render
|
|
//--------------------
|
|
this.opts.renderer.render(displayObject, renderTexture);
|
|
|
|
// restore position
|
|
displayObject.setTransform(...transform);
|
|
|
|
return renderTexture
|
|
}
|
|
|
|
/**
|
|
* Removed the first DisplayObject and adds the second one at the exactly same position.
|
|
*
|
|
* @private
|
|
* @param {PIXI.DisplayObject} first - The old DisplayObject.
|
|
* @param {PIXI.DisplayObject} second - The new DisplayObject.
|
|
* @return {Flippable} A reference to the flippable for chaining.
|
|
*/
|
|
switchDisplayObject(first, second) {
|
|
if (first && second && first.parent) {
|
|
const parent = first.parent;
|
|
const index = parent.getChildIndex(first);
|
|
parent.addChildAt(second, index);
|
|
parent.removeChild(first);
|
|
}
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
class Popover extends PIXI.Graphics {
|
|
constructor({
|
|
title = null,
|
|
text = null,
|
|
x = 0,
|
|
y = 0,
|
|
placement = 'top',
|
|
width = 250,
|
|
titleStyle = {},
|
|
textStyle = { fontSize: '1.6em' }
|
|
} = {}) {
|
|
super();
|
|
|
|
this.opts = {
|
|
title,
|
|
text,
|
|
x,
|
|
y,
|
|
placement,
|
|
width,
|
|
titleStyle,
|
|
textStyle
|
|
};
|
|
|
|
this.padding = 12;
|
|
|
|
let style = {
|
|
fontFamily: 'Arial',
|
|
fontSize: '2em',
|
|
stroke: '#f6f6f6',
|
|
strokeThickness: 3,
|
|
wordWrap: true,
|
|
wordWrapWidth: width - this.padding * 2
|
|
};
|
|
|
|
this.titleTextStyle = new PIXI.TextStyle(Object.assign({}, style, titleStyle));
|
|
this.textTextStyle = new PIXI.TextStyle(Object.assign({}, style, textStyle));
|
|
|
|
if (title || text) {
|
|
this.setup();
|
|
this.draw();
|
|
this.positioning();
|
|
}
|
|
}
|
|
|
|
setup() {
|
|
this.removeChildren();
|
|
|
|
if (this.opts.title) {
|
|
this.titleText = new PIXI.Text(this.opts.title, this.titleTextStyle);
|
|
this.titleText.position.set(this.padding, this.padding);
|
|
this.addChild(this.titleText);
|
|
}
|
|
|
|
this.titleY = this.titleText ? this.titleText.y : 0;
|
|
this.titleHeight = this.titleText ? this.titleText.height : 0;
|
|
|
|
if (this.opts.text) {
|
|
this.textText = new PIXI.Text(this.opts.text, this.textTextStyle);
|
|
this.textText.position.set(this.padding, this.titleY + this.titleHeight + this.padding);
|
|
this.addChild(this.textText);
|
|
}
|
|
|
|
this.textY = this.textText ? this.textText.y : 0;
|
|
this.textHeight = this.textText ? this.textText.height : 0;
|
|
}
|
|
|
|
close() {
|
|
this.parent.removeChild(this);
|
|
}
|
|
|
|
draw() {
|
|
this.clear();
|
|
this.beginFill(0xffffff, 1);
|
|
this.lineStyle(1, 0x282828, 0.5);
|
|
|
|
// Draw rounded rectangle
|
|
const height = this.height + this.padding;
|
|
this.drawRoundedRect(0, 0, this.opts.width, height, 8);
|
|
|
|
// Draw anchor
|
|
this.drawAnchor(this.opts.placement);
|
|
|
|
// Draw title background
|
|
if (this.opts.title) {
|
|
this.lineStyle(0);
|
|
this.beginFill(0xf7f7f7, 1);
|
|
let x = 1;
|
|
let y = this.titleText.x + this.titleText.height + this.padding / 2;
|
|
this.moveTo(x, y);
|
|
y = 9;
|
|
this.lineTo(x, y);
|
|
this.quadraticCurveTo(x, y - 8, x + 8, y - 8);
|
|
x += this.opts.width - 7;
|
|
y -= 8;
|
|
this.lineTo(x, y);
|
|
this.quadraticCurveTo(x + 5, y, x + 5, y + 8);
|
|
x += 5;
|
|
y += this.titleText.x + this.titleText.height + this.padding / 2;
|
|
this.lineTo(x, y);
|
|
if (this.opts.text) {
|
|
x = 1;
|
|
this.lineTo(x, y);
|
|
} else {
|
|
this.quadraticCurveTo(x, y, x - 5, y + 4);
|
|
x = 6;
|
|
y += 4;
|
|
this.lineTo(x, y);
|
|
this.quadraticCurveTo(x, y, x - 5, y - 4);
|
|
}
|
|
}
|
|
|
|
this.endFill();
|
|
}
|
|
|
|
drawAnchor(placement) {
|
|
let x = 0;
|
|
let y = 0;
|
|
|
|
switch (placement) {
|
|
case 'bottom':
|
|
if (this.opts.title) {
|
|
this.beginFill(0xf7f7f7, 1);
|
|
}
|
|
x = this.width / 2 - 10;
|
|
y = 1;
|
|
this.moveTo(x, y);
|
|
x += 10;
|
|
y -= 10;
|
|
this.lineTo(x, y);
|
|
x += 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
break
|
|
case 'right':
|
|
x = 1;
|
|
y = this.height / 2 - 10;
|
|
if (this.titleY + this.titleHeight > y) {
|
|
this.beginFill(0xf7f7f7, 1);
|
|
}
|
|
this.moveTo(x, y);
|
|
x -= 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
x += 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
break
|
|
case 'left':
|
|
x = this.width - 2;
|
|
y = this.height / 2 - 10;
|
|
if (this.titleY + this.titleHeight > y) {
|
|
this.beginFill(0xf7f7f7, 1);
|
|
}
|
|
this.moveTo(x, y);
|
|
x += 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
x -= 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
break
|
|
default:
|
|
x = this.width / 2 - 10;
|
|
y = this.height - 2;
|
|
this.moveTo(x, y);
|
|
x += 10;
|
|
y += 10;
|
|
this.lineTo(x, y);
|
|
x += 10;
|
|
y -= 10;
|
|
this.lineTo(x, y);
|
|
break
|
|
}
|
|
}
|
|
|
|
positioning() {
|
|
const x = this.opts.x;
|
|
const y = this.opts.y;
|
|
|
|
switch (this.opts.placement) {
|
|
case 'bottom':
|
|
this.position.set(x - this.width / 2, y + 10);
|
|
break
|
|
case 'right':
|
|
this.position.set(x, y - this.height / 2);
|
|
break
|
|
case 'left':
|
|
this.position.set(x - this.width, y - this.height / 2);
|
|
break
|
|
default:
|
|
this.position.set(x - this.width / 2, y - this.height);
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/** A container for scatter objects, which uses a single InteractionMapper
|
|
* for all children. This reduces the number of registered event handlers
|
|
* and covers the common use case that multiple objects are scattered
|
|
* on the same level.
|
|
*/
|
|
class ScatterContainer extends PIXI.Graphics {
|
|
/**
|
|
* @constructor
|
|
* @param {PIXI.Renderer} renderer - PIXI renderer, needed for hit testing
|
|
* @param {Bool} stopEvents - Whether events should be stopped or propagated
|
|
* @param {Bool} claimEvents - Whether events should be marked as claimed
|
|
* if findTarget return as non-null value.
|
|
* @param {PIXI.Container} container - A container for the scatter
|
|
* @param {Bool} showBounds - Show bounds for debugging purposes.
|
|
* @param {Bool} showTouches - Show touches and pointer for debugging purposes.
|
|
* @param {Color} backgroundColor - Set background color if specified.
|
|
* @param {PIXIApp} app - Needed if showBounds is true to register
|
|
* update handler.
|
|
*/
|
|
constructor(
|
|
renderer,
|
|
{
|
|
stopEvents = true,
|
|
claimEvents = true,
|
|
container = null,
|
|
showBounds = false,
|
|
showPolygon = false,
|
|
showTouches = false,
|
|
backgroundColor = null,
|
|
app = window.app
|
|
} = {}
|
|
) {
|
|
super();
|
|
this.container = container;
|
|
if (this.container)
|
|
this.containerDimensions = {
|
|
x: this.container.width,
|
|
y: this.container.height
|
|
};
|
|
this.backgroundWidth = null;
|
|
this.backgroundHeight = null;
|
|
this.app = app;
|
|
this.renderer = renderer;
|
|
this.stopEvents = stopEvents;
|
|
this.claimEvents = claimEvents;
|
|
this.delegate = new InteractionMapper$1(this.eventReceiver, this);
|
|
this.showBounds = showBounds;
|
|
this.showTouches = showTouches;
|
|
this.showPolygon = showPolygon;
|
|
this.backgroundColor = backgroundColor;
|
|
if (showBounds || showTouches || showPolygon) {
|
|
//console.log("Show TOUCHES!!!")
|
|
this.app.ticker.add(delta => this.update(delta), this);
|
|
}
|
|
if (backgroundColor) {
|
|
this.updateBackground();
|
|
}
|
|
}
|
|
|
|
updateBackground() {
|
|
this.clear();
|
|
let rect = this.bounds;
|
|
this.beginFill(this.backgroundColor, 1);
|
|
this.drawRect(0, 0, rect.width, rect.height);
|
|
this.endFill();
|
|
}
|
|
|
|
get eventReceiver() {
|
|
return this.renderer.plugins.interaction.interactionDOMElement
|
|
}
|
|
|
|
get bounds() {
|
|
let x = 0;
|
|
let y = 0;
|
|
|
|
// @container: We need to call the constant values, as the container
|
|
// gets resized, when a child moves outside the original boundaries.
|
|
let w = this.container ? this.containerDimensions.x : this.backgroundWidth || this.app.width;
|
|
let h = this.container ? this.containerDimensions.y : this.backgroundHeight || this.app.height;
|
|
|
|
if (this.app.fullscreen && this.app.monkeyPatchMapping) {
|
|
let fixed = this.mapPositionToPoint({ x: w, y: 0 });
|
|
if (fixed.x < w) {
|
|
w = fixed.x;
|
|
}
|
|
if (fixed.y > 0) {
|
|
y += fixed.y;
|
|
h -= fixed.y;
|
|
}
|
|
}
|
|
return new PIXI.Rectangle(x, y, w, h)
|
|
}
|
|
|
|
get center() {
|
|
let r = this.bounds;
|
|
return { x: r.width / 2, y: r.height / 2 }
|
|
}
|
|
|
|
get polygon() {
|
|
let r = this.bounds;
|
|
let w2 = r.width / 2;
|
|
let h2 = r.height / 2;
|
|
let center = { x: w2, y: h2 };
|
|
let polygon = new Polygon(center);
|
|
polygon.addPoint({ x: -w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: h2 });
|
|
polygon.addPoint({ x: -w2, y: h2 });
|
|
return polygon
|
|
}
|
|
|
|
update(dt) {
|
|
this.clear();
|
|
this.lineStyle(1, 0x0000ff);
|
|
if (this.showBounds) {
|
|
for (let child of this.children) {
|
|
if (child.scatter) {
|
|
//let {x, y, width, height} = child.scatter.throwBounds()
|
|
// new PIXI.Rectangle(x, y, width, height)
|
|
this.drawShape(child.scatter.bounds);
|
|
let center = child.scatter.center;
|
|
this.drawCircle(center.x, center.y, 4);
|
|
this.drawCircle(child.scatter.x, child.scatter.y, 4);
|
|
}
|
|
}
|
|
this.lineStyle(2, 0x0000ff);
|
|
this.drawShape(this.bounds);
|
|
}
|
|
if (this.showPolygon) {
|
|
this.lineStyle(2, 0xff0000);
|
|
for (let child of this.children) {
|
|
if (child.scatter) {
|
|
let polygon = child.scatter.polygon;
|
|
let shape = new PIXI.Polygon(polygon.flatAbsolutePoints());
|
|
//shape.close() not possible in PixiJS v5
|
|
this.drawShape(shape);
|
|
}
|
|
}
|
|
}
|
|
if (this.showTouches) {
|
|
let current = this.delegate.interaction.current;
|
|
for (let [key, point] of current.entries()) {
|
|
let local = this.mapPositionToPoint(point);
|
|
this.drawCircle(local.x, local.y, 12);
|
|
}
|
|
}
|
|
}
|
|
|
|
capture(event) {
|
|
if (this.stopEvents) Events$1.stop(event);
|
|
return true
|
|
}
|
|
|
|
fakeInteractionEvent(point, key) {
|
|
return { data: { global: point, key: key } }
|
|
}
|
|
|
|
findHitScatter(data, displayObject, hit) {
|
|
if (hit && this.hitScatter === null && typeof displayObject != undefined) {
|
|
this.hitScatter = displayObject.scatter ? displayObject.scatter : null;
|
|
}
|
|
}
|
|
|
|
mapPositionToPoint(point, element = null) {
|
|
// In case of nested scatters we get an additional parameter that
|
|
// contains the found scatter
|
|
let local = new PIXI.Point();
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
interactionManager.mapPositionToPoint(local, point.x, point.y);
|
|
if (element instanceof DisplayObjectScatter && element.displayObject.parent != null) {
|
|
return element.displayObject.parent.toLocal(local)
|
|
}
|
|
return local
|
|
}
|
|
|
|
/**
|
|
* New method hitTest implemented (in InteractionManager, since 4.5.0).
|
|
* See https://github.com/pixijs/pixi.js/pull/3878
|
|
*/
|
|
findTarget(event, local, global) {
|
|
if (event.claimedByScatter) {
|
|
return null
|
|
}
|
|
this.hitScatter = null;
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
let fakeEvent = this.fakeInteractionEvent(local);
|
|
interactionManager.processInteractive(fakeEvent, this, this.findHitScatter.bind(this), true);
|
|
if (this.claimEvents) event.claimedByScatter = this.hitScatter;
|
|
return this.hitScatter
|
|
}
|
|
|
|
findTargetNew(event, local, global) {
|
|
// UO: still problematic. Does not find non interactive elements
|
|
// which are needed for some stylus applications
|
|
if (event.claimedByScatter) {
|
|
return null
|
|
}
|
|
|
|
this.hitScatter = null;
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
let displayObject = interactionManager.hitTest(local, this);
|
|
if (displayObject != null && displayObject.scatter != null) this.hitScatter = displayObject.scatter;
|
|
if (this.claimEvents) event.claimedByScatter = this.hitScatter;
|
|
|
|
return this.hitScatter
|
|
}
|
|
|
|
onStart(event, interaction) {}
|
|
|
|
onMove(event, interaction) {}
|
|
|
|
onEnd(event, interaction) {
|
|
for (let key of interaction.ended.keys()) {
|
|
let point = interaction.ended.get(key);
|
|
if (interaction.isLongPress(key)) {
|
|
this.onLongPress(key, point);
|
|
}
|
|
if (interaction.isTap(key)) {
|
|
this.onTap(key, point);
|
|
}
|
|
}
|
|
}
|
|
|
|
onTap(key, point) {
|
|
console.info('ScatterContainer.onTap');
|
|
}
|
|
|
|
onLongPress(key, point) {
|
|
console.info('ScatterContainer.onLongPress');
|
|
}
|
|
|
|
bringToFront(displayObject) {
|
|
this.addChild(displayObject);
|
|
}
|
|
|
|
layout(width, height) {
|
|
this.backgroundWidth = width;
|
|
this.backgroundHeight = height;
|
|
if (this.backgroundColor) {
|
|
this.updateBackground();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A wrapper for child elements of a ScatterContainer. Can be used
|
|
* to combine scattered objects with non-scattered objects. Any
|
|
* PIXI.DisplayObject can be wrapped.
|
|
*/
|
|
class DisplayObjectScatter extends AbstractScatter {
|
|
constructor(
|
|
displayObject,
|
|
renderer,
|
|
{
|
|
x = null,
|
|
y = null,
|
|
minScale = 0.1,
|
|
maxScale = 1.0,
|
|
startScale = 1.0,
|
|
autoBringToFront = true,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
resizable = false,
|
|
movableX = true,
|
|
movableY = true,
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
autoThrow = true,
|
|
rotationDegrees = null,
|
|
rotation = null,
|
|
overdoScaling = 1.5,
|
|
onTransform = null,
|
|
onResize,
|
|
onThrowFinished = null
|
|
} = {}
|
|
) {
|
|
// For the simulation of named parameters,
|
|
// see: http://exploringjs.com/es6/ch_parameter-handling.html
|
|
super({
|
|
overdoScaling,
|
|
minScale,
|
|
maxScale,
|
|
startScale,
|
|
autoBringToFront,
|
|
translatable,
|
|
scalable,
|
|
rotatable,
|
|
resizable,
|
|
movableX,
|
|
movableY,
|
|
throwVisibility,
|
|
throwDamping,
|
|
autoThrow,
|
|
onThrowFinished,
|
|
rotationDegrees,
|
|
rotation,
|
|
onTransform
|
|
});
|
|
this.onResize = onResize;
|
|
this.displayObject = displayObject;
|
|
this.displayObject.scatter = this;
|
|
this.renderer = renderer;
|
|
this.scale = startScale;
|
|
this.rotationDegrees = this.startRotationDegrees;
|
|
|
|
// Only set x and y if they are specified.
|
|
// Otherwise the displayobject gets corrupted.
|
|
if (x != null) this.x = x;
|
|
if (y != null) this.y = y;
|
|
}
|
|
|
|
|
|
getWorldScatter() {
|
|
return this
|
|
}
|
|
|
|
/** Returns geometry data as object. **/
|
|
getState() {
|
|
return {
|
|
scale: this.scale,
|
|
x: this.x,
|
|
y: this.y,
|
|
rotation: this.rotation
|
|
}
|
|
}
|
|
|
|
setup() {
|
|
this.setupMouseWheelInteraction();
|
|
}
|
|
|
|
roundPixel(value) {
|
|
// UO: Should be obsolete because Renderer supports roundPixels by default
|
|
return value
|
|
}
|
|
|
|
get container() {
|
|
// return this.displayObject.parent
|
|
let obj = this.displayObject;
|
|
while (obj.parent != null && !(obj.parent instanceof ScatterContainer)) obj = obj.parent;
|
|
return obj.parent
|
|
}
|
|
|
|
get x() {
|
|
return this.position.x
|
|
}
|
|
|
|
set x(value) {
|
|
this.position.x = value;
|
|
}
|
|
|
|
get y() {
|
|
return this.position.y
|
|
}
|
|
|
|
set y(value) {
|
|
this.position.y = value;
|
|
}
|
|
|
|
get polygon() {
|
|
let polygon = new Polygon(this.center);
|
|
let w2 = this.width / 2;
|
|
let h2 = this.height / 2;
|
|
polygon.addPoint({ x: -w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: h2 });
|
|
polygon.addPoint({ x: -w2, y: h2 });
|
|
polygon.rotate(this.rotation);
|
|
return polygon
|
|
}
|
|
|
|
get containerBounds() {
|
|
return this.container.bounds
|
|
}
|
|
|
|
get containerPolygon() {
|
|
let container = this.container;
|
|
if (container == null) return null
|
|
return container.polygon
|
|
}
|
|
|
|
get position() {
|
|
return this.displayObject.position
|
|
}
|
|
|
|
set position(value) {
|
|
this.displayObject.position = value;
|
|
}
|
|
|
|
get scale() {
|
|
return this.displayObject.scale.x
|
|
}
|
|
|
|
set scale(value) {
|
|
this.displayObject.scale.x = value;
|
|
this.displayObject.scale.y = value;
|
|
}
|
|
|
|
get width() {
|
|
return this.displayObject.width
|
|
}
|
|
|
|
get height() {
|
|
return this.displayObject.height
|
|
}
|
|
|
|
get bounds() {
|
|
return this.displayObject.getBounds()
|
|
}
|
|
|
|
get pivot() {
|
|
return this.displayObject.pivot
|
|
}
|
|
|
|
get rotation() {
|
|
return this.displayObject.rotation
|
|
}
|
|
|
|
set rotation(value) {
|
|
this.displayObject.rotation = value;
|
|
}
|
|
|
|
get rotationDegrees() {
|
|
return Angle.radian2degree(this.displayObject.rotation)
|
|
}
|
|
|
|
set rotationDegrees(value) {
|
|
this.displayObject.rotation = Angle.degree2radian(value);
|
|
}
|
|
|
|
get center() {
|
|
let w2 = this.width / 2;
|
|
let h2 = this.height / 2;
|
|
let dist = Math.sqrt(w2 * w2 + h2 * h2);
|
|
let angle = Points.angle({ x: w2, y: h2 }, { x: 0, y: 0 });
|
|
let p = this.displayObject.x;
|
|
let c = Points.arc(this.position, this.rotation + angle, dist);
|
|
return c // Points.subtract(c, this.pivot)
|
|
}
|
|
|
|
get rotationOrigin() {
|
|
// In PIXI the default rotation and scale origin is the position
|
|
return this.position // Points.add(this.position, this.pivot)
|
|
}
|
|
|
|
mapPositionToContainerPoint(point) {
|
|
// UO: We need the coordinates related to this scatter in case
|
|
// of nested scatters
|
|
if (this.container != null) return this.container.mapPositionToPoint(point, this)
|
|
return point
|
|
}
|
|
|
|
capture(event) {
|
|
return true
|
|
}
|
|
|
|
bringToFront() {
|
|
if (this.autoBringToFront) {
|
|
if (this.displayObject.parent instanceof ScatterContainer) {
|
|
let scatterContainer = this.displayObject.parent;
|
|
scatterContainer.bringToFront(this.displayObject);
|
|
} else if (this.displayObject.parent != null && this.displayObject.parent.scatter) {
|
|
this.displayObject.parent.scatter.toFront(this.displayObject);
|
|
}
|
|
}
|
|
}
|
|
|
|
toFront(displayObject) {
|
|
this.displayObject.addChild(displayObject);
|
|
}
|
|
|
|
validScale(scale) {
|
|
scale = Math.max(scale, this.minScale);
|
|
scale = Math.min(scale, this.maxScale);
|
|
return scale
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
class Command extends PIXI.Graphics {
|
|
/*** Abstract base class for record, play, and stop commands. ***/
|
|
constructor(tools, selectedColor, shape) {
|
|
super();
|
|
this.tools = tools;
|
|
this.shape = shape;
|
|
this.selected = false;
|
|
this.disabled = false;
|
|
this.selectedColor = selectedColor;
|
|
this.draw();
|
|
this.setup();
|
|
}
|
|
|
|
setup() {}
|
|
|
|
draw() {
|
|
this.clear();
|
|
var color = this.selected ? this.selectedColor : 0xffffff;
|
|
this.lineStyle(0);
|
|
this.beginFill(color, 1);
|
|
this.drawShape(this.shape);
|
|
this.endFill();
|
|
}
|
|
|
|
select() {
|
|
this.selected = true;
|
|
this.draw();
|
|
}
|
|
|
|
deselect() {
|
|
this.selected = false;
|
|
this.draw();
|
|
}
|
|
|
|
toggle() {
|
|
this.selected = !this.selected;
|
|
this.draw();
|
|
}
|
|
|
|
stop() {
|
|
this.selected = false;
|
|
this.draw();
|
|
}
|
|
}
|
|
|
|
class RecordCommand extends Command {
|
|
/*** Records events for later replay. ***/
|
|
setup() {
|
|
this.recorder = new EventRecorder();
|
|
}
|
|
|
|
toggle() {
|
|
super.toggle();
|
|
if (!this.selected) {
|
|
this.recorder.stopRecording();
|
|
}
|
|
}
|
|
|
|
recordEvent(event) {
|
|
this.recorder.record(event);
|
|
}
|
|
|
|
normalize(value, limit) {
|
|
return value / limit
|
|
}
|
|
|
|
normalizeX(value) {
|
|
return this.normalize(value, window.innerWidth)
|
|
}
|
|
|
|
normalizeY(value) {
|
|
return this.normalize(value, window.innerHeight)
|
|
}
|
|
|
|
whileNotStopped() {
|
|
return this.tools.play.selected
|
|
}
|
|
|
|
startReplay() {
|
|
let whileCondition = this.whileNotStopped.bind(this);
|
|
this.recorder.startReplay(whileCondition, () => this.tools.play.stop());
|
|
}
|
|
}
|
|
|
|
class PlayCommand extends Command {
|
|
/*** Plays recorded events. ***/
|
|
toggle() {
|
|
super.toggle();
|
|
if (this.selected && this.tools.record.recorder.recorded.length > 0) {
|
|
this.tools.startReplay();
|
|
}
|
|
}
|
|
}
|
|
|
|
class StopCommand extends Command {
|
|
/*** Stops recording and playing. ***/
|
|
toggle() {
|
|
super.toggle();
|
|
this.tools.record.stop();
|
|
this.tools.play.stop();
|
|
setTimeout(this.deselect.bind(this), 500);
|
|
}
|
|
}
|
|
|
|
class RecorderTools extends PIXI.Container {
|
|
constructor(renderer) {
|
|
super(renderer);
|
|
this.renderer = renderer;
|
|
this.setupToolbar();
|
|
this.replayRate = 100.0;
|
|
this.onReset = null;
|
|
this.touchGraphics = new PIXI.Graphics();
|
|
this.showTouches();
|
|
}
|
|
|
|
setup(container) {
|
|
// Since this delegate might shadow another delegate, we mus avoid
|
|
// capturing PointerEvents.
|
|
this.delegate = new InteractionMapper(container, this, {
|
|
capturePointerEvents: false
|
|
});
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
return this
|
|
}
|
|
|
|
setupToolbar() {
|
|
this.toolbar = new PIXI.Graphics();
|
|
this.record = new RecordCommand(this, 0xcc0000, new PIXI.Circle(0, 0, 16));
|
|
this.play = new PlayCommand(this, 0x0000cc, new PIXI.Polygon(0, 16, 32, 16 + 16, 0, 16 + 32, 0, 16));
|
|
this.stop = new StopCommand(this, 0x0000cc, new PIXI.Rectangle(0, 0, 32, 32));
|
|
this.toolbar.addChild(this.record).position.set(44, 48);
|
|
this.toolbar.addChild(this.play).position.set(44 + 44, 16);
|
|
this.toolbar.addChild(this.stop).position.set(44 + 44 + 44 + 16, 32);
|
|
this.updateToolbar();
|
|
this.addChild(this.toolbar);
|
|
}
|
|
|
|
updateToolbar() {
|
|
var graphics = this.toolbar;
|
|
graphics.clear();
|
|
graphics.beginFill(0x000000, 0.5);
|
|
graphics.lineStyle(2, 0xffffff, 1);
|
|
graphics.drawRoundedRect(16, 16, 44 * 4 + 8, 64, 8);
|
|
graphics.endFill();
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
console.log('onMouseWheel missing');
|
|
}
|
|
|
|
onPress(point) {
|
|
if (this.record.containsPoint(point)) {
|
|
this.record.toggle();
|
|
}
|
|
if (this.play.containsPoint(point)) {
|
|
this.play.toggle();
|
|
}
|
|
if (this.stop.containsPoint(point)) {
|
|
this.stop.toggle();
|
|
if (this.onReset) {
|
|
this.onReset();
|
|
}
|
|
}
|
|
}
|
|
|
|
mapPositionToPoint(point) {
|
|
let local = new PIXI.Point();
|
|
this.renderer.plugins.interaction.mapPositionToPoint(local, point.x, point.y);
|
|
return local
|
|
}
|
|
|
|
extractLocal(event) {
|
|
return this.mapPositionToPoint(Events.extractPoint(event))
|
|
}
|
|
|
|
capture(event) {
|
|
if (typeof event.mouseDownSubstitute != 'undefined') return false
|
|
return true
|
|
}
|
|
|
|
startReplay() {
|
|
if (this.onReset) {
|
|
this.onReset();
|
|
}
|
|
this.record.startReplay();
|
|
}
|
|
|
|
showTouches() {
|
|
this.addChild(this.touchGraphics);
|
|
}
|
|
|
|
recordEvent(event) {
|
|
if (this.record.selected) {
|
|
this.record.recordEvent(event);
|
|
}
|
|
}
|
|
|
|
onStart(event, interaction) {
|
|
let local = this.extractLocal(event);
|
|
if (!this.toolbar.containsPoint(local)) {
|
|
this.recordEvent(event);
|
|
this.updateTouchGraphics(interaction);
|
|
}
|
|
}
|
|
|
|
onMove(event, interaction) {
|
|
let local = this.extractLocal(event);
|
|
if (!this.toolbar.containsPoint(local)) {
|
|
this.recordEvent(event);
|
|
this.updateTouchGraphics(interaction);
|
|
}
|
|
}
|
|
|
|
onEnd(event, interaction) {
|
|
let local = this.extractLocal(event);
|
|
if (this.toolbar.containsPoint(local)) {
|
|
this.onPress(local);
|
|
} else {
|
|
this.recordEvent(event);
|
|
this.updateTouchGraphics(interaction);
|
|
}
|
|
}
|
|
|
|
updateTouchGraphics(interaction) {
|
|
let current = interaction.current;
|
|
let graphics = this.touchGraphics;
|
|
if (graphics != null) {
|
|
graphics.clear();
|
|
for (let key of current.keys()) {
|
|
if (interaction.ended.has(key)) {
|
|
continue
|
|
}
|
|
let p = current.get(key);
|
|
if (key == 'mouse') {
|
|
graphics.beginFill(0xcc0000, 0.5);
|
|
} else {
|
|
graphics.beginFill(0xcccccc, 0.5);
|
|
}
|
|
graphics.drawCircle(p.x, p.y, 20);
|
|
}
|
|
graphics.endFill();
|
|
}
|
|
}
|
|
}
|
|
|
|
class AppTest extends PIXIApp {
|
|
constructor(canvas, container) {
|
|
super({ view: canvas, backgroundColor: 0x000000 });
|
|
this.container = container;
|
|
}
|
|
|
|
sceneFactory() {
|
|
return new RecorderTools(this.renderer)
|
|
}
|
|
|
|
setup() {
|
|
super.setup();
|
|
this.scene.setup(this.container);
|
|
}
|
|
|
|
run(reset = null) {
|
|
this.scene.onReset = reset;
|
|
console.log('Running AppTest');
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Defines usefull default text styles.
|
|
*/
|
|
class FontInfo {
|
|
static get small() {
|
|
return app.theme.textStyleSmall
|
|
}
|
|
|
|
static get normal() {
|
|
return app.theme.textStyle
|
|
}
|
|
|
|
static get centered() {
|
|
return Object.assign({}, app.theme.textStyle, { align: 'center' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static methods to support hyphenation of lines.
|
|
*
|
|
* @class Hypenate
|
|
*/
|
|
class Hypenate {
|
|
static splitPart(part) {
|
|
let parts = part.split('-');
|
|
if (parts.length == 1) return [part]
|
|
let result = [];
|
|
let last = parts.pop();
|
|
for (let p of parts) {
|
|
result.push(p + '-');
|
|
}
|
|
result.push(last);
|
|
return result.filter(p => p.length > 0)
|
|
}
|
|
|
|
static splitWord(word) {
|
|
if (typeof language == 'undefined') {
|
|
if (word.indexOf('-') > -1) {
|
|
return word.split('-')
|
|
}
|
|
return [word]
|
|
}
|
|
let parts = language.hyphenate(word);
|
|
let result = [];
|
|
for (let part of parts) {
|
|
for (let splitted of this.splitPart(part)) {
|
|
result.push(splitted);
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
static abbreviateLine(label, style, width) {
|
|
const pixiStyle = new PIXI.TextStyle(style);
|
|
let metrics = PIXI.TextMetrics.measureText(label, pixiStyle);
|
|
while (metrics.width > width && label.length > 3) {
|
|
label = label.slice(0, label.length - 1);
|
|
metrics = PIXI.TextMetrics.measureText(label, pixiStyle);
|
|
}
|
|
label = label.slice(0, label.length - 1);
|
|
return label + '…'
|
|
}
|
|
|
|
static splitLine(line, pixiStyle, width, space, minus) {
|
|
let x = 0;
|
|
let result = '';
|
|
let words = line.split(' ');
|
|
for (let word of words) {
|
|
let wordMetrics = PIXI.TextMetrics.measureText(word, pixiStyle);
|
|
if (x + wordMetrics.width >= width) {
|
|
let parts = this.splitWord(word);
|
|
let newWord = '';
|
|
if (parts.length == 1) {
|
|
newWord += '\n' + word + ' ';
|
|
x = wordMetrics.width + space.width;
|
|
} else {
|
|
let first = true;
|
|
let lastPart = '';
|
|
for (let part of parts) {
|
|
let partMetrics = PIXI.TextMetrics.measureText(part, pixiStyle);
|
|
if (x + partMetrics.width + space.width > width) {
|
|
newWord += (first || lastPart.endsWith('-') ? '\n' : '-\n') + part;
|
|
x = partMetrics.width;
|
|
} else {
|
|
newWord += part;
|
|
x += partMetrics.width;
|
|
}
|
|
lastPart = part;
|
|
first = false;
|
|
}
|
|
x += space.width;
|
|
}
|
|
result += newWord + ' ';
|
|
} else {
|
|
result += word + ' ';
|
|
x += wordMetrics.width + space.width;
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Main method and entry point for text hyphenation
|
|
*
|
|
* @static
|
|
* @param {*} text
|
|
* @param {*} style
|
|
* @param {*} width
|
|
* @memberof Hypenate
|
|
* @returns {string}
|
|
*/
|
|
static splitLines(text, style, width) {
|
|
const pixiStyle = new PIXI.TextStyle(style);
|
|
const lines = text.split('\n');
|
|
const space = PIXI.TextMetrics.measureText(' ', pixiStyle);
|
|
const minus = PIXI.TextMetrics.measureText('-', pixiStyle);
|
|
let result = [];
|
|
for (let line of lines) {
|
|
result.push(this.splitLine(line, pixiStyle, width, space, minus));
|
|
}
|
|
return result.join('\n')
|
|
}
|
|
}
|
|
|
|
class TextLabel extends PIXI.Text {
|
|
/**
|
|
*Creates an instance of TextLabel.
|
|
* @param {string} text - The string that you would like the text to display
|
|
* @param {object|PIXI.TextStyle} [style] - The style parameters
|
|
* @param {canvas}
|
|
* @memberof TextLabel
|
|
*/
|
|
constructor(text, style = null, canvas = null, { minZoom = 0.1, maxZoom = 10 } = {}) {
|
|
super(text, style, canvas);
|
|
this.normFontSize = this.style.fontSize;
|
|
this.minZoom = minZoom;
|
|
this.maxZoom = maxZoom;
|
|
}
|
|
|
|
zoom(factor) {
|
|
let oldValue = parseFloat(this.style.fontSize) / this.normFontSize;
|
|
let value = oldValue * factor;
|
|
this.setZoom(value);
|
|
}
|
|
|
|
setZoom(value) {
|
|
let oldValue = parseFloat(this.style.fontSize) / this.normFontSize;
|
|
if (value > this.maxZoom) {
|
|
value = this.maxZoom;
|
|
}
|
|
if (value < this.minZoom) {
|
|
value = this.minZoom;
|
|
}
|
|
if (value != oldValue) {
|
|
this.style.fontSize = Math.max(value * this.normFontSize, 1);
|
|
}
|
|
}
|
|
|
|
setZoomAndScale(scale) {
|
|
this.scale.set(1 / scale);
|
|
this.setZoom(scale);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A specialization of the PIXI.Graphics class that allows to
|
|
* resuse and place labels across different layout variants
|
|
*
|
|
* @export
|
|
* @class LabeledGraphics
|
|
* @extends {PIXI.Graphics}
|
|
*/
|
|
class LabeledGraphics extends PIXI.Graphics {
|
|
/**
|
|
* Creates an instance of LabeledGraphics and defines a local label cache.
|
|
*
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
constructor() {
|
|
super();
|
|
this.labels = new Map();
|
|
}
|
|
|
|
_createText(label, fontInfo) {
|
|
return new TextLabel(label, fontInfo)
|
|
}
|
|
|
|
/**
|
|
* Main additional method. Ensures that a text object is created that is cached
|
|
* under the given key.
|
|
*
|
|
* @param {*} key - The cache key
|
|
* @param {*} label - The label to show
|
|
* @param {*} [attrs={}] - Defines attributes of the text object.
|
|
* align: 'right', 'left', or 'center'
|
|
* justify: 'top', 'bottom', or 'center'
|
|
* maxLines: {integer} truncates the text and adds ellipsis
|
|
* maxHeight: {number} truncates text that needs more space and adds ellipsis
|
|
* maxWidth: {number} word wraps text using hyphenation if possible
|
|
* @param {*} [fontInfo=FontInfo.normal] - Defines PIXI.TextStyle attributes
|
|
* @returns {PIXI.Text} - instance
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
ensureLabel(key, label, attrs = {}, fontInfo = FontInfo.normal) {
|
|
if (attrs.maxWidth && attrs.maxLines == 1) {
|
|
label = Hypenate.abbreviateLine(label, fontInfo, attrs.maxWidth);
|
|
} else {
|
|
if (attrs.maxWidth) {
|
|
label = Hypenate.splitLines(label, fontInfo, attrs.maxWidth);
|
|
}
|
|
if (attrs.maxLines) {
|
|
label = this.truncateLabel(label, fontInfo, attrs.maxLines);
|
|
}
|
|
if (attrs.maxHeight) {
|
|
let styleInfo = new PIXI.TextStyle(fontInfo);
|
|
let metrics = PIXI.TextMetrics.measureText(label, styleInfo);
|
|
let maxLines = Math.max(attrs.maxHeight / metrics.lineHeight, 1);
|
|
label = this.truncateLabel(label, fontInfo, maxLines);
|
|
}
|
|
}
|
|
|
|
if (!this.labels.has(key)) {
|
|
let text = this._createText(label, fontInfo);
|
|
this.labels.set(key, text);
|
|
this.addChild(text);
|
|
}
|
|
let text = this.labels.get(key);
|
|
for (let k in attrs) {
|
|
text[k] = attrs[k];
|
|
}
|
|
if (label != text.text) text.text = label;
|
|
// We do not follow the flexbox jargon and use align for x and justify for y axis
|
|
// This deviation is needed to ensure backward compatability
|
|
switch (attrs.justify || null) {
|
|
case 'top':
|
|
text.anchor.y = 0;
|
|
break
|
|
case 'bottom':
|
|
text.anchor.x = 1;
|
|
break
|
|
default:
|
|
text.anchor.y = 0.5;
|
|
break
|
|
}
|
|
switch (attrs.align) {
|
|
case 'right':
|
|
text.anchor.x = 1;
|
|
break
|
|
case 'center':
|
|
text.anchor.x = 0.5;
|
|
break
|
|
default:
|
|
text.anchor.x = 0;
|
|
break
|
|
}
|
|
text.visible = true;
|
|
return text
|
|
}
|
|
|
|
/**
|
|
* Private method that truncates the text and adds an ellipsis if there are more lines
|
|
* than wanted
|
|
*
|
|
* @param {*} text
|
|
* @param {*} style
|
|
* @param {*} [maxLines=Infinity]
|
|
* @returns {string}
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
truncateLabel(text, style, maxLines = Infinity) {
|
|
if (maxLines === Infinity) {
|
|
return text
|
|
}
|
|
const { wordWrapWidth } = style;
|
|
const pixiStyle = new PIXI.TextStyle(style);
|
|
const { lines } = PIXI.TextMetrics.measureText(text, pixiStyle);
|
|
let newText = text;
|
|
if (lines.length > maxLines) {
|
|
const truncatedLines = lines.slice(0, maxLines);
|
|
const lastLine = truncatedLines[truncatedLines.length - 1];
|
|
const words = lastLine.split(' ');
|
|
const wordMetrics = PIXI.TextMetrics.measureText(`\u00A0\n...\n${words.join('\n')}`, pixiStyle);
|
|
const [spaceLength, dotsLength, ...wordLengths] = wordMetrics.lineWidths;
|
|
const { text: newLastLine } = wordLengths.reduce(
|
|
(data, wordLength, i) => {
|
|
if (data.length + wordLength + spaceLength >= wordWrapWidth) {
|
|
return { ...data, length: wordWrapWidth }
|
|
}
|
|
return {
|
|
text: `${data.text}${i > 0 ? ' ' : ''}${words[i]}`,
|
|
length: data.length + wordLength + spaceLength
|
|
}
|
|
},
|
|
{ text: '', length: dotsLength }
|
|
);
|
|
truncatedLines[truncatedLines.length - 1] = `${newLastLine}...`;
|
|
newText = truncatedLines.join('\n');
|
|
}
|
|
return newText
|
|
}
|
|
|
|
/**
|
|
* Returns the label for the given key.
|
|
*
|
|
* @param {*} key
|
|
* @returns {Object}
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
getLabel(key) {
|
|
return this.labels.get(key)
|
|
}
|
|
|
|
/**
|
|
* Hides the label with the given key.
|
|
* @param {*} key
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
hideLabel(key) {
|
|
let label = this.labels.get(key);
|
|
if (label) {
|
|
label.visible = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes the label with the given key.
|
|
* @param {*} key
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
removeLabel(key) {
|
|
let label = this.labels.get(key);
|
|
this.labels.delete(key);
|
|
label.destroy();
|
|
}
|
|
|
|
/**
|
|
* Ensures that labels are hidden on clear.
|
|
*
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
clear() {
|
|
super.clear();
|
|
for (let key of this.labels.keys()) {
|
|
this.hideLabel(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs debugging infos
|
|
*
|
|
* @memberof LabeledGraphics
|
|
*/
|
|
debugInfos() {
|
|
console.log({ size: this.labels.size, labels: this.labels });
|
|
}
|
|
}
|
|
|
|
const labelCache = new Map();
|
|
|
|
function getTexture(label, fontInfo = FontInfo.normal) {
|
|
let key = label + fontInfo.fontFamily + fontInfo.fontSize;
|
|
|
|
if (labelCache.has(key)) {
|
|
return labelCache.get(key)
|
|
}
|
|
let expandedFont = Object.assign({}, fontInfo);
|
|
expandedFont.fontSize *= window.devicePixelRatio;
|
|
let text = new PIXI.Text(label, expandedFont);
|
|
text.updateText();
|
|
labelCache.set(key, text.texture);
|
|
return text.texture
|
|
}
|
|
|
|
class SpriteLabel extends PIXI.Sprite {
|
|
constructor(label, fontInfo) {
|
|
let texture = getTexture(label, fontInfo);
|
|
super(texture);
|
|
this.label = label;
|
|
this.fontInfo = fontInfo;
|
|
this.scale.set(0.8 / window.devicePixelRatio);
|
|
}
|
|
|
|
set text(label) {
|
|
this.label = label;
|
|
this.texture = getTexture(label, this.fontInfo);
|
|
}
|
|
|
|
get text() {
|
|
return this.label
|
|
}
|
|
}
|
|
|
|
class BitmapLabeledGraphics extends LabeledGraphics {
|
|
_createText(label, fontInfo) {
|
|
let texture = getTexture(label, fontInfo);
|
|
return new SpriteLabel(texture)
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
class Ticks {
|
|
get reservedPrefixes() {
|
|
return ['decade', 'year', 'month', 'day', 'hour', 'minute', 'second']
|
|
}
|
|
|
|
static get largeTickSize() {
|
|
return 4.2
|
|
}
|
|
|
|
get minWidth() {
|
|
return 10
|
|
}
|
|
|
|
format(available) {
|
|
return { year: '2-digit', timeZone: 'UTC' }
|
|
}
|
|
|
|
get minLabelWidth() {
|
|
return 44
|
|
}
|
|
|
|
get formatKey() {
|
|
return this.key
|
|
}
|
|
|
|
dateKey(date) {
|
|
return this.key + date.getFullYear()
|
|
}
|
|
|
|
*iter(start, end) {
|
|
let date = this.iterStart(start);
|
|
while (date <= end) {
|
|
yield date;
|
|
date = this.next(date);
|
|
}
|
|
yield date;
|
|
}
|
|
|
|
*iterRanges(range) {
|
|
for (let date of this.iter(range.start, range.end)) {
|
|
let next = this.next(date);
|
|
yield { start: date, end: next };
|
|
}
|
|
}
|
|
|
|
selectedRange(timeline, info) {
|
|
let first = null;
|
|
let last = null;
|
|
let visibleFirst = null;
|
|
let visibleLast = null;
|
|
let units = 0;
|
|
for (let { start, end } of this.iterRanges(info)) {
|
|
if (timeline.visibleRange(start, end)) {
|
|
if (first == null) {
|
|
first = start;
|
|
}
|
|
last = end;
|
|
}
|
|
if (timeline.visibleDate(start) && timeline.visibleDate(end)) {
|
|
units += 1;
|
|
if (visibleFirst == null) {
|
|
visibleFirst = start;
|
|
}
|
|
visibleLast = end;
|
|
}
|
|
}
|
|
if (first == null) return info
|
|
return {
|
|
start: first,
|
|
end: last,
|
|
visibleStart: visibleFirst,
|
|
visibleEnd: visibleLast,
|
|
units: units
|
|
}
|
|
}
|
|
|
|
drawTick(timeline, x, y, date) {
|
|
let visible = date > timeline.start && date < timeline.end;
|
|
if (!visible) return false
|
|
timeline.drawTick(x);
|
|
return true
|
|
}
|
|
|
|
toLocaleString(date, format) {
|
|
return date.toLocaleDateString('de', format)
|
|
}
|
|
|
|
draw(timeline, range, width, height, available, format, nextFormat, level, extraTicks = false) {
|
|
let first = null;
|
|
let last = null;
|
|
let keyedFormat = format ? format[this.formatKey] : null;
|
|
let keyedNextFormat = nextFormat ? nextFormat[this.formatKey] : null;
|
|
let redundant = nextFormat ? this.formatKey in nextFormat : false;
|
|
let fullyRedundant = keyedFormat != null && keyedFormat == keyedNextFormat;
|
|
let y = timeline.getY();
|
|
for (let { start, end } of this.iterRanges(range)) {
|
|
let x = timeline.toX(start);
|
|
let xx = x;
|
|
let yy = y + timeline.tickLabelOffset(-1);
|
|
if (this.drawTick(timeline, x, y, start) && format) {
|
|
let key = this.dateKey(start);
|
|
let text = this.toLocaleString(start, format);
|
|
let align = 'center';
|
|
if (nextFormat) {
|
|
yy = y + timeline.tickLabelOffset(-1, 1);
|
|
align = 'left';
|
|
timeline.drawTick(x, Ticks.largeTickSize);
|
|
let nextX = timeline.toX(end) - 100;
|
|
if (x < 0 && nextX > -100 && !redundant) {
|
|
xx = Math.min(4, nextX);
|
|
} else {
|
|
xx -= 2;
|
|
}
|
|
} else if (level > 0) {
|
|
xx = x + available / 2;
|
|
}
|
|
|
|
if (!fullyRedundant) {
|
|
timeline.ensureLabel(key, text, { x: xx, y: yy, align }, FontInfo.small);
|
|
}
|
|
if (extraTicks) timeline.drawTick(x, -level);
|
|
}
|
|
if (timeline.visibleRange(start, end)) {
|
|
if (first == null) first = start;
|
|
last = end;
|
|
}
|
|
}
|
|
if (first == null) return null
|
|
return { start: first, end: last }
|
|
}
|
|
|
|
drawExtra(timeline, range, size) {
|
|
for (let { start } of this.iterRanges(range)) {
|
|
if (timeline.visibleDate(start)) {
|
|
let x = timeline.toX(start);
|
|
timeline.drawTick(x, -size);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class DecadeTicks extends Ticks {
|
|
get milliseconds() {
|
|
return 10 * 365 * 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
format(available) {
|
|
return { year: 'numeric', timeZone: 'UTC' }
|
|
}
|
|
|
|
selection(timeline, dates, selected) {
|
|
return dates
|
|
}
|
|
|
|
get key() {
|
|
return 'decade'
|
|
}
|
|
|
|
get formatKey() {
|
|
return 'year'
|
|
}
|
|
|
|
iterStart(start) {
|
|
let modulo = start.getFullYear() % 10;
|
|
let year = start.getFullYear() - modulo;
|
|
return Dates.create(year, 0, 1)
|
|
}
|
|
|
|
next(decade) {
|
|
return Dates.nextYear(decade, 10)
|
|
}
|
|
}
|
|
|
|
class YearTicks extends Ticks {
|
|
get milliseconds() {
|
|
return 365 * 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
format(available) {
|
|
if (available < 44) return { year: '2-digit', timeZone: 'UTC' }
|
|
return { year: 'numeric', timeZone: 'UTC' }
|
|
}
|
|
|
|
get minLabelWidth() {
|
|
return 22
|
|
}
|
|
|
|
get key() {
|
|
return 'year'
|
|
}
|
|
|
|
iterStart(start) {
|
|
return Dates.create(start.getFullYear(), 0, 1)
|
|
}
|
|
|
|
next(year) {
|
|
return Dates.nextYear(year)
|
|
}
|
|
}
|
|
|
|
class MonthTicks extends Ticks {
|
|
get milliseconds() {
|
|
return (365 / 12) * 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
format(available) {
|
|
let format = { month: 'narrow', timeZone: 'UTC' };
|
|
if (available > 44) format.month = 'short';
|
|
if (available > 66) format.year = '2-digit';
|
|
if (available > 100) {
|
|
format.month = 'long';
|
|
format.year = 'numeric';
|
|
}
|
|
return format
|
|
}
|
|
|
|
get minLabelWidth() {
|
|
return 32
|
|
}
|
|
|
|
get key() {
|
|
return 'month'
|
|
}
|
|
|
|
dateKey(date) {
|
|
return this.key + date.getFullYear() + date.getMonth()
|
|
}
|
|
|
|
iterStart(start) {
|
|
return Dates.create(start.getFullYear(), start.getMonth(), 1)
|
|
}
|
|
|
|
next(month) {
|
|
return Dates.nextMonth(month)
|
|
}
|
|
}
|
|
|
|
class DayTicks extends Ticks {
|
|
get milliseconds() {
|
|
return 24 * 60 * 60 * 1000
|
|
}
|
|
|
|
format(available) {
|
|
let format = { day: 'numeric', timeZone: 'UTC' };
|
|
if (available > 44) format.month = 'short';
|
|
if (available > 100) {
|
|
format.month = 'long';
|
|
format.year = '2-digit';
|
|
}
|
|
if (available > 150) {
|
|
format.weekday = 'short';
|
|
}
|
|
if (available > 200) {
|
|
format.year = 'numeric';
|
|
format.weekday = 'long';
|
|
}
|
|
return format
|
|
}
|
|
|
|
get key() {
|
|
return 'day'
|
|
}
|
|
|
|
dateKey(date) {
|
|
return this.key + date.getFullYear() + date.getMonth() + date.getDate()
|
|
}
|
|
|
|
iterStart(start) {
|
|
return Dates.create(start.getFullYear(), start.getMonth(), start.getDate())
|
|
}
|
|
|
|
next(date) {
|
|
return Dates.nextDay(date)
|
|
}
|
|
}
|
|
|
|
class TimeTicks {
|
|
constructor(...ticks) {
|
|
this.ticks = ticks;
|
|
}
|
|
|
|
selectedRange(timeline) {
|
|
let info = { start: timeline.start, end: timeline.end, units: 0 };
|
|
for (let ticks of this.ticks) {
|
|
info = ticks.selectedRange(timeline, info);
|
|
if (info.units > 1) {
|
|
timeline.selection = [info.visibleStart, info.visibleEnd];
|
|
return
|
|
}
|
|
}
|
|
timeline.selection = [info.start, info.end];
|
|
}
|
|
|
|
draw(timeline, width, height) {
|
|
let range = timeline;
|
|
let start = timeline.toX(range.start);
|
|
let end = timeline.toX(range.end);
|
|
let size = end - start;
|
|
let duration = timeline.end - timeline.start;
|
|
let formats = new Map();
|
|
let nextFormats = new Map();
|
|
let availables = new Map();
|
|
let previous = null;
|
|
let visible = [];
|
|
for (let ticks of this.ticks) {
|
|
let amount = ticks.milliseconds / duration;
|
|
let available = amount * size;
|
|
availables.set(ticks, available);
|
|
if (available < ticks.minWidth) break
|
|
formats.set(ticks, available < ticks.minLabelWidth ? null : ticks.format(available));
|
|
nextFormats.set(previous, formats.get(ticks));
|
|
previous = ticks;
|
|
visible.push(ticks);
|
|
}
|
|
let level = 0;
|
|
let ranges = [];
|
|
for (let ticks of visible) {
|
|
if (range == null) continue
|
|
range = ticks.draw(
|
|
timeline,
|
|
range,
|
|
width,
|
|
height,
|
|
availables.get(ticks),
|
|
formats.get(ticks),
|
|
nextFormats.get(ticks),
|
|
level
|
|
);
|
|
if (range) {
|
|
ranges.push({ ticks, range });
|
|
}
|
|
level += 1;
|
|
}
|
|
|
|
let extraLevel = ranges.length - 1;
|
|
let extraStart = extraLevel;
|
|
for (let { ticks, range } of ranges) {
|
|
ticks.drawExtra(timeline, range, extraLevel);
|
|
extraLevel -= 1;
|
|
if (extraLevel <= 0) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
timeline.drawTick(start, Ticks.largeTickSize);
|
|
timeline.drawTick(start, -extraStart);
|
|
timeline.drawTick(end, Ticks.largeTickSize);
|
|
timeline.drawTick(end, -extraStart);
|
|
}
|
|
}
|
|
|
|
class ColorRanges {
|
|
constructor(label, color, ranges) {
|
|
this.label = label;
|
|
this.color = color;
|
|
this.ranges = ranges;
|
|
}
|
|
|
|
draw(timeline, width, height, size = 12) {
|
|
let minimum = 1 / Capabilities.devicePixelRatio;
|
|
let h2 = size;
|
|
timeline.lineStyle(size, this.color);
|
|
for (let range of this.ranges) {
|
|
if (range.to === null) {
|
|
range.to = Dates.nextDay(range.from);
|
|
}
|
|
let start = timeline.toX(range.from);
|
|
let end = timeline.toX(range.to);
|
|
if (end < start + minimum) {
|
|
end = start + minimum;
|
|
}
|
|
timeline.moveTo(start, h2);
|
|
timeline.lineTo(end, h2);
|
|
}
|
|
}
|
|
}
|
|
|
|
class Timeline extends BitmapLabeledGraphics {
|
|
constructor(width, height, { ticks = null, baseLine = 0.5, showRange = true, throwDamping = 0.95 } = {}) {
|
|
super();
|
|
this.wantedWidth = width;
|
|
this.wantedHeight = height;
|
|
this.extraLeft = 0;
|
|
this.extraRight = 0;
|
|
this.inset = 5;
|
|
this.showRange = showRange;
|
|
this.baseLine = baseLine;
|
|
this.tickHeight = 4;
|
|
this.zoom = 1;
|
|
this.minZoom = 1;
|
|
this.maxZoom = 12000;
|
|
this.scroll = 0;
|
|
this.draggable = false;
|
|
this.deltas = [];
|
|
this.labelDates = [];
|
|
this.colorRanges = [];
|
|
this.rangeColors = new Cycle(Colors.eminence, Colors.steelblue, Colors.ochre, Colors.turquoise);
|
|
this.callbacks = [];
|
|
this.onTapCallbacks = [];
|
|
this.onDoubleTapCallbacks = [];
|
|
this.onLongPressCallbacks = [];
|
|
this.progress = null;
|
|
this.start = null;
|
|
this.end = null;
|
|
this.selection = null;
|
|
this.autoScroll = false;
|
|
this.direction = -1;
|
|
this.throwDamping = throwDamping;
|
|
this.timeticks = ticks || new TimeTicks(new DecadeTicks(), new YearTicks(), new MonthTicks(), new DayTicks());
|
|
this.labelPrefix = '__';
|
|
}
|
|
|
|
updateSelection() {
|
|
if (this.visibleDate(this.start) && this.visibleDate(this.end)) {
|
|
this.selection = [this.start, this.end];
|
|
} else {
|
|
this.timeticks.selectedRange(this);
|
|
}
|
|
|
|
return this.selection
|
|
}
|
|
|
|
addCallback(callback) {
|
|
this.callbacks.push(callback);
|
|
}
|
|
|
|
addTabCallback(callback) {
|
|
this.onTapCallbacks.push(callback);
|
|
}
|
|
|
|
addDoubleTapCallback(callback) {
|
|
this.onDoubleTapCallbacks.push(callback);
|
|
}
|
|
|
|
addLongPressCallback(callback) {
|
|
this.onLongPressCallbacks.push(callback);
|
|
}
|
|
|
|
addLabels(labels) {
|
|
this.labelDates = labels;
|
|
}
|
|
|
|
range(start, end) {
|
|
this.start = start;
|
|
this.end = end;
|
|
}
|
|
|
|
draw(width, height) {
|
|
this.wantedWidth = width;
|
|
this.wantedHeight = height;
|
|
this.redraw();
|
|
}
|
|
|
|
updateColorRanges(w, h) {
|
|
let xx = w - this.inset;
|
|
let size = FontInfo.small.fontSize;
|
|
let yy = h - size;
|
|
for (let i = this.colorRanges.length - 1; i >= 0; i--) {
|
|
let cr = this.colorRanges[i];
|
|
let label = cr.label;
|
|
cr.draw(this, w, h);
|
|
let current = this.ensureLabel(
|
|
'colorRange:' + label,
|
|
label,
|
|
{ x: xx, y: yy, align: 'right' },
|
|
FontInfo.small
|
|
);
|
|
let r = current.getBounds();
|
|
xx -= r.width + 16;
|
|
|
|
this.lineStyle(size, cr.color);
|
|
this.moveTo(xx, yy);
|
|
this.lineTo(xx + size, yy);
|
|
xx -= size + 4;
|
|
}
|
|
}
|
|
|
|
drawSelectedRamge(selected) {
|
|
this.lineStyle(2, app.theme.primaryColor);
|
|
let start = this.toX(selected[0]);
|
|
let end = this.toX(selected[1]);
|
|
this.moveTo(start, 0);
|
|
this.lineTo(end, 0);
|
|
this.drawTick(start, -1.5, 0);
|
|
this.drawTick(end, -1.5, 0);
|
|
}
|
|
|
|
redraw() {
|
|
this.clear();
|
|
let h = this.wantedHeight;
|
|
let w = this.wantedWidth;
|
|
let y = this.getY();
|
|
this.prepareLabels();
|
|
this.updateColorRanges(w, h);
|
|
|
|
this.lineStyle(2, 0xffffff);
|
|
if (this.start != null && this.end != null) {
|
|
this.moveTo(this.toX(this.start), y);
|
|
this.lineTo(this.toX(this.end), y);
|
|
this.updateTicksAndLabels(w, h);
|
|
this.updateSelection();
|
|
let selected = this.selection;
|
|
if (selected[0] != this.start && selected[1] != this.end) {
|
|
if (this.showRange) this.drawSelectedRamge(selected);
|
|
}
|
|
for (let callback of this.callbacks) {
|
|
callback(this.scroll, this.zoom, this.selection);
|
|
}
|
|
} else {
|
|
this.moveTo(this.inset, y);
|
|
this.lineTo(w - this.inset, y);
|
|
}
|
|
|
|
if (this.progress != null && this.progress < 1) {
|
|
this.lineStyle(2, 0xccccff);
|
|
this.moveTo(this.inset, y);
|
|
this.lineTo((w - this.inset) * this.progress, y);
|
|
}
|
|
}
|
|
|
|
totalWidth(bounded = false) {
|
|
let w = this.wantedWidth - 2 * this.inset;
|
|
return w * this.validZoom(this.zoom, bounded)
|
|
}
|
|
|
|
validZoom(zoom, bounded = true) {
|
|
let overshoot = bounded ? 1.0 : 2.0;
|
|
zoom = Math.max(zoom, this.minZoom / overshoot);
|
|
zoom = Math.min(zoom, this.maxZoom * overshoot);
|
|
return zoom
|
|
}
|
|
|
|
getY() {
|
|
return this.wantedHeight * this.baseLine
|
|
}
|
|
|
|
toX(date) {
|
|
let total = this.end - this.start;
|
|
let offset = this.inset + this.scroll;
|
|
let width = this.totalWidth();
|
|
let delta = date - this.start;
|
|
let ratio = delta / total;
|
|
return offset + ratio * width
|
|
}
|
|
|
|
fromX(value) {
|
|
let total = this.end - this.start;
|
|
let offset = this.inset + this.scroll;
|
|
let width = this.totalWidth();
|
|
let ratio = (value - offset) / width;
|
|
let time = this.start.getTime() + total * ratio;
|
|
let date = new Date(time);
|
|
return date
|
|
}
|
|
|
|
drawTick(x, direction = 1, y = null) {
|
|
if (y == null) {
|
|
y = this.getY();
|
|
}
|
|
this.moveTo(x, y);
|
|
this.lineTo(x, y - this.tickHeight * direction * this.direction);
|
|
}
|
|
|
|
prepareLabels() {
|
|
for (let key of this.labels.keys()) {
|
|
if (!key.startsWith(this.labelPrefix)) this.labels.get(key).visible = false;
|
|
}
|
|
}
|
|
|
|
updateTicksAndLabels(width, height) {
|
|
this.drawTick(this.toX(this.start));
|
|
this.drawTick(this.toX(this.end));
|
|
this.timeticks.draw(this, width, height);
|
|
this.updateLabels(width, height);
|
|
}
|
|
|
|
visibleDate(date, offset = 0) {
|
|
if (date >= this.start && date <= this.end) {
|
|
let x = this.toX(date) + offset;
|
|
return x > 0 && x < this.wantedWidth
|
|
}
|
|
return false
|
|
}
|
|
|
|
visibleRange(start, end) {
|
|
let x = this.toX(start);
|
|
if (x > this.wantedWidth) return false
|
|
x = this.toX(end);
|
|
if (x < 0) return false
|
|
return true
|
|
}
|
|
|
|
tickLabelOffset(direction = 1, level = 0) {
|
|
let fs = FontInfo.small.fontSize;
|
|
let dh = fs + level * (fs + 2);
|
|
return this.direction * direction * dh
|
|
}
|
|
|
|
updateLabels(width, height) {
|
|
let h2 = height / 2;
|
|
if (this.labelDates) {
|
|
let last = null;
|
|
let y = h2 + this.tickLabelOffset();
|
|
for (let i = this.labelDates.length - 1; i >= 0; i--) {
|
|
let [label, date] = this.labelDates[i];
|
|
let align = 'center'; // (last == null) ? 'right' : 'left'
|
|
let x = this.toX(date);
|
|
let current = this.ensureLabel(
|
|
this.labelPrefix + label,
|
|
label,
|
|
{
|
|
x: x,
|
|
y: y,
|
|
align
|
|
},
|
|
FontInfo.small
|
|
);
|
|
let r = current.getBounds();
|
|
current.visible = !(last != null && r.x + r.width > last.x);
|
|
if (current.visible) {
|
|
this.drawTick(x, -1);
|
|
last = r;
|
|
}
|
|
}
|
|
} else {
|
|
let start = this.start.toLocaleDateString('de', {
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric'
|
|
});
|
|
let end = this.end.toLocaleDateString('de', {
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric'
|
|
});
|
|
this.ensureLabel(this.labelPrefix + 'start', start, {
|
|
x: this.toX(this.start),
|
|
y: h2
|
|
});
|
|
this.ensureLabel(this.labelPrefix + 'end', end, {
|
|
x: this.toX(this.end),
|
|
y: h2,
|
|
align: 'right'
|
|
});
|
|
}
|
|
}
|
|
|
|
onZoom(zoom, anchor) {
|
|
let date = this.fromX(anchor.x);
|
|
let newZoom = this.validZoom(this.zoom * zoom, false);
|
|
this.zoom = newZoom;
|
|
let newX = this.toX(date);
|
|
this.scroll += anchor.x - newX;
|
|
}
|
|
|
|
onStart(event, interaction) {
|
|
this.killTweens();
|
|
this.deltas = [];
|
|
this.validScroll();
|
|
// if (typeof ThrowPropsPlugin != 'undefined') {
|
|
// ThrowPropsPlugin.track(this, 'delta')
|
|
// }
|
|
}
|
|
|
|
onMove(event, interaction) {
|
|
let delta = interaction.delta();
|
|
if (delta == null) {
|
|
return
|
|
}
|
|
this.scroll += delta.x;
|
|
if (this.draggable) {
|
|
this.y += delta.y;
|
|
}
|
|
while (this.deltas.length > 10) {
|
|
this.deltas.pop(0);
|
|
}
|
|
this.deltas.push(delta.x);
|
|
if (interaction.current.size > 1) {
|
|
this.onZoom(delta.zoom, delta.about);
|
|
}
|
|
this.redraw();
|
|
}
|
|
|
|
onEnd(event, interaction) {
|
|
// if (typeof ThrowPropsPlugin != 'undefined') {
|
|
// let vel = ThrowPropsPlugin.getVelocity(this, 'delta')
|
|
// ThrowPropsPlugin.untrack(this)
|
|
// }
|
|
this.killTweens();
|
|
this.redraw();
|
|
let delta = 0;
|
|
for (let d of this.deltas) {
|
|
delta += d;
|
|
}
|
|
if (this.deltas.length > 0) {
|
|
delta /= this.deltas.length;
|
|
}
|
|
this.autoScroll = true;
|
|
let anchor = interaction.current.mean();
|
|
this.keepInBounds(delta, anchor);
|
|
for (let key of interaction.ended.keys()) {
|
|
if (interaction.isDoubleTap(key)) {
|
|
this.onDoubleTap(event, interaction, key);
|
|
} else if (interaction.isTap(key)) {
|
|
this.onTap(event, interaction, key);
|
|
} else if (interaction.isLongPress(key)) {
|
|
this.onLongPress(event, interaction, key);
|
|
}
|
|
}
|
|
}
|
|
|
|
onLongPress(event, interaction, key) {
|
|
for (let callback of this.onLongPressCallbacks) {
|
|
callback(event, interaction, key);
|
|
}
|
|
}
|
|
|
|
onTap(event, interaction, key) {
|
|
for (let callback of this.onTapCallbacks) {
|
|
callback(event, interaction, key);
|
|
}
|
|
}
|
|
|
|
onDoubleTap(event, interaction, key) {
|
|
for (let callback of this.onDoubleTapCallbacks) {
|
|
callback(event, interaction, key);
|
|
}
|
|
}
|
|
|
|
_scrollMinimum(bounded) {
|
|
let total = this.totalWidth(bounded);
|
|
return -total + this.wantedWidth - 2 * this.inset
|
|
}
|
|
|
|
_scrollMaximum(bounded) {
|
|
let total = this.totalWidth(bounded);
|
|
let limit = this.wantedWidth;
|
|
if (total > limit) return 0
|
|
let w = limit - 2 * this.inset;
|
|
return (w - total) / 2
|
|
}
|
|
|
|
scrollMinimum(bounded) {
|
|
return this._scrollMinimum(bounded) - this.extraRight
|
|
}
|
|
|
|
scrollMaximum(bounded) {
|
|
return this._scrollMaximum(bounded) + this.extraLeft
|
|
}
|
|
|
|
killTweens() {
|
|
TweenLite.killTweensOf(this);
|
|
this.autoScroll = false;
|
|
}
|
|
|
|
validScroll(bounded = true) {
|
|
let minimum = this.scrollMinimum(bounded);
|
|
let maximum = this.scrollMaximum(bounded);
|
|
if (this.scroll < minimum) {
|
|
this.scroll = minimum;
|
|
}
|
|
if (this.scroll > maximum) {
|
|
this.scroll = maximum;
|
|
}
|
|
}
|
|
|
|
keepInBounds(delta, anchor) {
|
|
let bounded = true;
|
|
let minimum = this.scrollMinimum(bounded);
|
|
let maximum = this.scrollMaximum(bounded);
|
|
let tweens = {};
|
|
if (this.zoom > this.maxZoom) {
|
|
tweens.zoom = this.maxZoom;
|
|
let date = this.fromX(anchor.x);
|
|
let oldZoom = this.zoom;
|
|
this.zoom = this.maxZoom;
|
|
let newX = this.toX(date);
|
|
tweens.scroll = this.scroll + anchor.x - newX;
|
|
this.zoom = oldZoom;
|
|
} else {
|
|
if (this.zoom < this.minZoom) {
|
|
tweens.zoom = this.minZoom;
|
|
}
|
|
if (this.scroll > maximum) {
|
|
tweens.scroll = maximum;
|
|
}
|
|
if (this.scroll < minimum) {
|
|
tweens.scroll = minimum;
|
|
}
|
|
}
|
|
if (!isEmpty(tweens)) {
|
|
tweens.onUpdate = () => {
|
|
this.redraw();
|
|
};
|
|
TweenLite.to(this, 0.5, tweens).delay(0.1);
|
|
return
|
|
}
|
|
this.scroll += delta;
|
|
delta *= this.throwDamping;
|
|
this.redraw();
|
|
if (Math.abs(delta) > 1 && this.autoScroll) {
|
|
setTimeout(() => this.keepInBounds(delta, anchor), 1000 / 100);
|
|
}
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
this.killTweens();
|
|
let direction = event.detail < 0 || event.wheelDelta > 0;
|
|
let anchor = { x: event.clientX, y: event.clientY };
|
|
const zoomFactor = 1.5;
|
|
this.onZoom(direction ? zoomFactor : 1 / zoomFactor, anchor);
|
|
this.redraw();
|
|
this.keepInBounds(0, anchor);
|
|
}
|
|
|
|
showRanges(ranges, label = 'Untitled', color = null) {
|
|
for (let cr of this.colorRanges) {
|
|
if (cr.label == label) return
|
|
}
|
|
while (this.colorRanges.length >= this.rangeColors.length) {
|
|
this.colorRanges.shift();
|
|
}
|
|
this.colorRanges.push(new ColorRanges(label, color, ranges));
|
|
this.redraw();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pixi.js scrollbox: a masked content box that can scroll vertically or horizontally with scrollbars
|
|
*/
|
|
class Scrollbox extends PIXI.Container {
|
|
/**
|
|
* create a scrollbox
|
|
* @param {object} options
|
|
* @param {boolean} [options.dragScroll=true] user may drag the content area to scroll content
|
|
* @param {string} [options.overflowX=auto] (none, scroll, hidden, auto) this changes whether the scrollbar is shown
|
|
* @param {string} [options.overflowY=auto] (none, scroll, hidden, auto) this changes whether the scrollbar is shown
|
|
* @param {string} [options.overflow] (none, scroll, hidden, auto) sets overflowX and overflowY to this value
|
|
* @param {number} [options.boxWidth=100] width of scrollbox including scrollbar (in pixels)
|
|
* @param {number} [options.boxHeight=100] height of scrollbox including scrollbar (in pixels)
|
|
* @param {number} [options.scrollbarSize=10] size of scrollbar (in pixels)
|
|
* @param {number} [options.scrollbarOffsetHorizontal=0] offset of horizontal scrollbar (in pixels)
|
|
* @param {number} [options.scrollbarOffsetVertical=0] offset of vertical scrollbar (in pixels)
|
|
* @param {boolean} [options.stopPropagation=true] call stopPropagation on any events that impact scrollbox
|
|
* @param {number} [options.scrollbarBackground=0xdddddd] background color of scrollbar
|
|
* @param {number} [options.scrollbarBackgroundAlpha=1] alpha of background of scrollbar
|
|
* @param {number} [options.scrollbarForeground=0x888888] foreground color of scrollbar
|
|
* @param {number} [options.scrollbarForegroundAlpha=1] alpha of foreground of scrollbar
|
|
* @param {string} [options.underflow=top-left] what to do when content underflows the scrollbox size: none: do nothing; (left/right/center AND top/bottom/center); OR center (e.g., 'top-left', 'center', 'none', 'bottomright')
|
|
* @param {(boolean|number)} [options.fade] fade the scrollbar when not in use (true = 1000ms)
|
|
* @param {number} [options.fadeWait=3000] time to wait before fading the scrollbar if options.fade is set
|
|
* @param {(string|function)} [options.fadeEase=easeInOutSine] easing function to use for fading
|
|
*/
|
|
constructor(options) {
|
|
super();
|
|
this.options = Object.assign(
|
|
{},
|
|
{
|
|
boxWidth: 100,
|
|
boxHeight: 100,
|
|
scrollbarSize: 10,
|
|
scrollbarBackground: 14540253,
|
|
scrollbarBackgroundAlpha: 1,
|
|
scrollbarForeground: 8947848,
|
|
scrollbarForegroundAlpha: 1,
|
|
dragScroll: true,
|
|
stopPropagation: true,
|
|
scrollbarOffsetHorizontal: 0,
|
|
scrollbarOffsetVertical: 0,
|
|
underflow: 'top-left',
|
|
fadeScrollbar: false,
|
|
fadeWait: 3000,
|
|
fadeEase: 'easeInOutSine'
|
|
},
|
|
options
|
|
);
|
|
this.ease = new PIXI.extras.Ease.list();
|
|
|
|
this.on('added', event => {
|
|
this.update();
|
|
});
|
|
|
|
/**
|
|
* content in placed in here
|
|
* you can use any function from pixi-viewport on content to manually move the content (see https://davidfig.github.io/pixi-viewport/jsdoc/)
|
|
* @type {PIXI.extras.Viewport}
|
|
*/
|
|
this.content = this.addChild(
|
|
new PIXI.extras.Viewport({
|
|
passiveWheel: this.options.stopPropagation,
|
|
stopPropagation: this.options.stopPropagation,
|
|
screenWidth: this.options.boxWidth,
|
|
screenHeight: this.options.boxHeight
|
|
})
|
|
);
|
|
this.content.decelerate().on('moved', () => this._drawScrollbars());
|
|
|
|
/**
|
|
* graphics element for drawing the scrollbars
|
|
* @type {PIXI.Graphics}
|
|
*/
|
|
this.scrollbar = this.addChild(new PIXI.Graphics());
|
|
this.scrollbar.interactive = true;
|
|
this.scrollbar.on('pointerdown', this.scrollbarDown, this);
|
|
this.interactive = true;
|
|
this.on('pointermove', this.scrollbarMove, this);
|
|
this.on('pointerup', this.scrollbarUp, this);
|
|
this.on('pointercancel', this.scrollbarUp, this);
|
|
this.on('pointerupoutside', this.scrollbarUp, this);
|
|
this._maskContent = this.addChild(new PIXI.Graphics());
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* offset of horizontal scrollbar (in pixels)
|
|
* @type {number}
|
|
*/
|
|
get scrollbarOffsetHorizontal() {
|
|
return this.options.scrollbarOffsetHorizontal
|
|
}
|
|
set scrollbarOffsetHorizontal(value) {
|
|
this.options.scrollbarOffsetHorizontal = value;
|
|
}
|
|
|
|
/**
|
|
* offset of vertical scrollbar (in pixels)
|
|
* @type {number}
|
|
*/
|
|
get scrollbarOffsetVertical() {
|
|
return this.options.scrollbarOffsetVertical
|
|
}
|
|
set scrollbarOffsetVertical(value) {
|
|
this.options.scrollbarOffsetVertical = value;
|
|
}
|
|
|
|
/**
|
|
* disable the scrollbox (if set to true this will also remove the mask)
|
|
* @type {boolean}
|
|
*/
|
|
get disable() {
|
|
return this._disabled
|
|
}
|
|
set disable(value) {
|
|
if (this._disabled !== value) {
|
|
this._disabled = value;
|
|
this.update();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* call stopPropagation on any events that impact scrollbox
|
|
* @type {boolean}
|
|
*/
|
|
get stopPropagation() {
|
|
return this.options.stopPropagation
|
|
}
|
|
set stopPropagation(value) {
|
|
this.options.stopPropagation = value;
|
|
}
|
|
|
|
/**
|
|
* user may drag the content area to scroll content
|
|
* @type {boolean}
|
|
*/
|
|
get dragScroll() {
|
|
return this.options.dragScroll
|
|
}
|
|
set dragScroll(value) {
|
|
this.options.dragScroll = value;
|
|
if (value) {
|
|
this.content.drag();
|
|
} else {
|
|
this.content.removePlugin('drag');
|
|
}
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* width of scrollbox including the scrollbar (if visible)- this changes the size and not the scale of the box
|
|
* @type {number}
|
|
*/
|
|
get boxWidth() {
|
|
return this.options.boxWidth
|
|
}
|
|
set boxWidth(value) {
|
|
this.options.boxWidth = value;
|
|
this.content.screenWidth = value;
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* sets overflowX and overflowY to (scroll, hidden, auto) changing whether the scrollbar is shown
|
|
* scroll = always show scrollbar
|
|
* hidden = hide overflow and do not show scrollbar
|
|
* auto = if content is larger than box size, then show scrollbar
|
|
* @type {string}
|
|
*/
|
|
get overflow() {
|
|
return this.options.overflow
|
|
}
|
|
set overflow(value) {
|
|
this.options.overflow = value;
|
|
this.options.overflowX = value;
|
|
this.options.overflowY = value;
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* sets overflowX to (scroll, hidden, auto) changing whether the scrollbar is shown
|
|
* scroll = always show scrollbar
|
|
* hidden = hide overflow and do not show scrollbar
|
|
* auto = if content is larger than box size, then show scrollbar
|
|
* @type {string}
|
|
*/
|
|
get overflowX() {
|
|
return this.options.overflowX
|
|
}
|
|
set overflowX(value) {
|
|
this.options.overflowX = value;
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* sets overflowY to (scroll, hidden, auto) changing whether the scrollbar is shown
|
|
* scroll = always show scrollbar
|
|
* hidden = hide overflow and do not show scrollbar
|
|
* auto = if content is larger than box size, then show scrollbar
|
|
* @type {string}
|
|
*/
|
|
get overflowY() {
|
|
return this.options.overflowY
|
|
}
|
|
set overflowY(value) {
|
|
this.options.overflowY = value;
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* height of scrollbox including the scrollbar (if visible) - this changes the size and not the scale of the box
|
|
* @type {number}
|
|
*/
|
|
get boxHeight() {
|
|
return this.options.boxHeight
|
|
}
|
|
set boxHeight(value) {
|
|
this.options.boxHeight = value;
|
|
this.content.screenHeight = value;
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* scrollbar size in pixels
|
|
* @type {number}
|
|
*/
|
|
get scrollbarSize() {
|
|
return this.options.scrollbarSize
|
|
}
|
|
set scrollbarSize(value) {
|
|
this.options.scrollbarSize = value;
|
|
}
|
|
|
|
/**
|
|
* width of scrollbox less the scrollbar (if visible)
|
|
* @type {number}
|
|
* @readonly
|
|
*/
|
|
get contentWidth() {
|
|
return this.options.boxWidth - (this.isScrollbarVertical ? this.options.scrollbarSize : 0)
|
|
}
|
|
|
|
/**
|
|
* height of scrollbox less the scrollbar (if visible)
|
|
* @type {number}
|
|
* @readonly
|
|
*/
|
|
get contentHeight() {
|
|
return this.options.boxHeight - (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0)
|
|
}
|
|
|
|
/**
|
|
* is the vertical scrollbar visible
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get isScrollbarVertical() {
|
|
return this._isScrollbarVertical
|
|
}
|
|
|
|
/**
|
|
* is the horizontal scrollbar visible
|
|
* @type {boolean}
|
|
* @readonly
|
|
*/
|
|
get isScrollbarHorizontal() {
|
|
return this._isScrollbarHorizontal
|
|
}
|
|
|
|
/**
|
|
* top coordinate of scrollbar
|
|
*/
|
|
get scrollTop() {
|
|
return this.content.top
|
|
}
|
|
|
|
/**
|
|
* left coordinate of scrollbar
|
|
*/
|
|
get scrollLeft() {
|
|
return this.content.left
|
|
}
|
|
|
|
/**
|
|
* width of content area
|
|
* if not set then it uses content.width to calculate width
|
|
*/
|
|
get scrollWidth() {
|
|
return this._scrollWidth || this.content.width
|
|
}
|
|
set scrollWidth(value) {
|
|
this._scrollWidth = value;
|
|
}
|
|
|
|
/**
|
|
* height of content area
|
|
* if not set then it uses content.height to calculate height
|
|
*/
|
|
get scrollHeight() {
|
|
return this._scrollHeight || this.content.height
|
|
}
|
|
set scrollHeight(value) {
|
|
this._scrollHeight = value;
|
|
}
|
|
|
|
/**
|
|
* draws scrollbars
|
|
* @private
|
|
*/
|
|
_drawScrollbars() {
|
|
this._isScrollbarHorizontal =
|
|
this.overflowX === 'scroll'
|
|
? true
|
|
: ['hidden', 'none'].indexOf(this.overflowX) !== -1
|
|
? false
|
|
: this.scrollWidth > this.options.boxWidth;
|
|
this._isScrollbarVertical =
|
|
this.overflowY === 'scroll'
|
|
? true
|
|
: ['hidden', 'none'].indexOf(this.overflowY) !== -1
|
|
? false
|
|
: this.scrollHeight > this.options.boxHeight;
|
|
this.scrollbar.clear();
|
|
let options = {};
|
|
options.left = 0;
|
|
options.right = this.scrollWidth + (this._isScrollbarVertical ? this.options.scrollbarSize : 0);
|
|
options.top = 0;
|
|
options.bottom = this.scrollHeight + (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0);
|
|
const width = this.scrollWidth + (this.isScrollbarVertical ? this.options.scrollbarSize : 0);
|
|
const height = this.scrollHeight + (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0);
|
|
this.scrollbarTop = (this.content.top / height) * this.boxHeight;
|
|
this.scrollbarTop = this.scrollbarTop < 0 ? 0 : this.scrollbarTop;
|
|
this.scrollbarHeight = (this.boxHeight / height) * this.boxHeight;
|
|
this.scrollbarHeight =
|
|
this.scrollbarTop + this.scrollbarHeight > this.boxHeight
|
|
? this.boxHeight - this.scrollbarTop
|
|
: this.scrollbarHeight;
|
|
this.scrollbarLeft = (this.content.left / width) * this.boxWidth;
|
|
this.scrollbarLeft = this.scrollbarLeft < 0 ? 0 : this.scrollbarLeft;
|
|
this.scrollbarWidth = (this.boxWidth / width) * this.boxWidth;
|
|
this.scrollbarWidth =
|
|
this.scrollbarWidth + this.scrollbarLeft > this.boxWidth
|
|
? this.boxWidth - this.scrollbarLeft
|
|
: this.scrollbarWidth;
|
|
if (this.isScrollbarVertical) {
|
|
this.scrollbar
|
|
.beginFill(this.options.scrollbarBackground, this.options.scrollbarBackgroundAlpha)
|
|
.drawRect(
|
|
this.boxWidth - this.scrollbarSize + this.options.scrollbarOffsetVertical,
|
|
0,
|
|
this.scrollbarSize,
|
|
this.boxHeight
|
|
)
|
|
.endFill();
|
|
}
|
|
if (this.isScrollbarHorizontal) {
|
|
this.scrollbar
|
|
.beginFill(this.options.scrollbarBackground, this.options.scrollbarBackgroundAlpha)
|
|
.drawRect(
|
|
0,
|
|
this.boxHeight - this.scrollbarSize + this.options.scrollbarOffsetHorizontal,
|
|
this.boxWidth,
|
|
this.scrollbarSize
|
|
)
|
|
.endFill();
|
|
}
|
|
if (this.isScrollbarVertical) {
|
|
this.scrollbar
|
|
.beginFill(this.options.scrollbarForeground, this.options.scrollbarForegroundAlpha)
|
|
.drawRect(
|
|
this.boxWidth - this.scrollbarSize + this.options.scrollbarOffsetVertical,
|
|
this.scrollbarTop,
|
|
this.scrollbarSize,
|
|
this.scrollbarHeight
|
|
)
|
|
.endFill();
|
|
}
|
|
if (this.isScrollbarHorizontal) {
|
|
this.scrollbar
|
|
.beginFill(this.options.scrollbarForeground, this.options.scrollbarForegroundAlpha)
|
|
.drawRect(
|
|
this.scrollbarLeft,
|
|
this.boxHeight - this.scrollbarSize + this.options.scrollbarOffsetHorizontal,
|
|
this.scrollbarWidth,
|
|
this.scrollbarSize
|
|
)
|
|
.endFill();
|
|
}
|
|
// this.content.forceHitArea = new PIXI.Rectangle(0, 0 , this.boxWidth, this.boxHeight)
|
|
this.activateFade();
|
|
}
|
|
|
|
/**
|
|
* draws mask layer
|
|
* @private
|
|
*/
|
|
_drawMask() {
|
|
this._maskContent
|
|
.beginFill(0)
|
|
.drawRect(0, 0, this.boxWidth, this.boxHeight)
|
|
.endFill();
|
|
this.content.mask = this._maskContent;
|
|
}
|
|
|
|
/**
|
|
* call when scrollbox content changes
|
|
*/
|
|
update() {
|
|
this.content.mask = null;
|
|
this._maskContent.clear();
|
|
if (!this._disabled) {
|
|
this._drawScrollbars();
|
|
this._drawMask();
|
|
if (this.options.dragScroll) {
|
|
const direction =
|
|
this.isScrollbarHorizontal && this.isScrollbarVertical
|
|
? 'all'
|
|
: this.isScrollbarHorizontal
|
|
? 'x'
|
|
: 'y';
|
|
if (direction !== null) {
|
|
this.content
|
|
.drag({ clampWheel: true, direction })
|
|
.clamp({ direction, underflow: this.options.underflow });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* show the scrollbar and restart the timer for fade if options.fade is set
|
|
*/
|
|
activateFade() {
|
|
if (this.options.fade) {
|
|
if (this.fade) {
|
|
this.ease.remove(this.fade);
|
|
}
|
|
this.scrollbar.alpha = 1;
|
|
const time = this.options.fade === true ? 1000 : this.options.fade;
|
|
this.fade = this.ease.to(this.scrollbar, { alpha: 0 }, time, {
|
|
wait: this.options.fadeWait,
|
|
ease: this.options.fadeEase
|
|
});
|
|
this.fade.on('each', () => (this.content.dirty = true));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* handle pointer down on scrollbar
|
|
* @param {PIXI.interaction.InteractionEvent} e
|
|
* @private
|
|
*/
|
|
scrollbarDown(e) {
|
|
const local = this.toLocal(e.data.global);
|
|
if (this.isScrollbarHorizontal) {
|
|
if (local.y > this.boxHeight - this.scrollbarSize) {
|
|
if (local.x >= this.scrollbarLeft && local.x <= this.scrollbarLeft + this.scrollbarWidth) {
|
|
this.pointerDown = { type: 'horizontal', last: local };
|
|
} else {
|
|
if (local.x > this.scrollbarLeft) {
|
|
this.content.left += this.content.worldScreenWidth;
|
|
this.update();
|
|
} else {
|
|
this.content.left -= this.content.worldScreenWidth;
|
|
this.update();
|
|
}
|
|
}
|
|
if (this.options.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if (this.isScrollbarVertical) {
|
|
if (local.x > this.boxWidth - this.scrollbarSize) {
|
|
if (local.y >= this.scrollbarTop && local.y <= this.scrollbarTop + this.scrollbarWidth) {
|
|
this.pointerDown = { type: 'vertical', last: local };
|
|
} else {
|
|
if (local.y > this.scrollbarTop) {
|
|
this.content.top += this.content.worldScreenHeight;
|
|
this.update();
|
|
} else {
|
|
this.content.top -= this.content.worldScreenHeight;
|
|
this.update();
|
|
}
|
|
}
|
|
if (this.options.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* handle pointer move on scrollbar
|
|
* @param {PIXI.interaction.InteractionEvent} e
|
|
* @private
|
|
*/
|
|
scrollbarMove(e) {
|
|
if (this.pointerDown) {
|
|
if (this.pointerDown.type === 'horizontal') {
|
|
const local = this.toLocal(e.data.global);
|
|
this.content.left += local.x - this.pointerDown.last.x;
|
|
this.pointerDown.last = local;
|
|
this.update();
|
|
} else if (this.pointerDown.type === 'vertical') {
|
|
const local = this.toLocal(e.data.global);
|
|
this.content.top += local.y - this.pointerDown.last.y;
|
|
this.pointerDown.last = local;
|
|
this.update();
|
|
}
|
|
if (this.options.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* handle pointer down on scrollbar
|
|
* @private
|
|
*/
|
|
scrollbarUp() {
|
|
this.pointerDown = null;
|
|
}
|
|
|
|
/**
|
|
* resize the mask for the container
|
|
* @param {object} options
|
|
* @param {number} [options.boxWidth] width of scrollbox including scrollbar (in pixels)
|
|
* @param {number} [options.boxHeight] height of scrollbox including scrollbar (in pixels)
|
|
* @param {number} [options.scrollWidth] set the width of the inside of the scrollbox (leave null to use content.width)
|
|
* @param {number} [options.scrollHeight] set the height of the inside of the scrollbox (leave null to use content.height)
|
|
*/
|
|
resize(options) {
|
|
this.options.boxWidth = typeof options.boxWidth !== 'undefined' ? options.boxWidth : this.options.boxWidth;
|
|
this.options.boxHeight = typeof options.boxHeight !== 'undefined' ? options.boxHeight : this.options.boxHeight;
|
|
if (options.scrollWidth) {
|
|
this.scrollWidth = options.scrollWidth;
|
|
}
|
|
if (options.scrollHeight) {
|
|
this.scrollHeight = options.scrollHeight;
|
|
}
|
|
this.content.resize(this.options.boxWidth, this.options.boxHeight, this.scrollWidth, this.scrollHeight);
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* ensure that the bounding box is visible
|
|
* @param {number} x - relative to content's coordinate system
|
|
* @param {number} y
|
|
* @param {number} width
|
|
* @param {number} height
|
|
*/
|
|
ensureVisible(x, y, width, height) {
|
|
this.content.ensureVisible(x, y, width, height);
|
|
this._drawScrollbars();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS Scrollview.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 600,
|
|
* height: 400
|
|
* }).setup().run()
|
|
*
|
|
* // Create the Scrollview
|
|
* app.loader
|
|
* .add('elephant', './assets/elephant-1.jpg')
|
|
* .load((loader, resources) => {
|
|
* const sprite = new PIXI.Sprite(resources.elephant.texture)
|
|
* const scrollview = new Scrollview({boxWidth: 400, boxHeight: 300})
|
|
* scrollview.content.addChild(sprite)
|
|
* app.scene.addChild(scrollview)
|
|
*
|
|
* @class
|
|
* @extends PIXI.extras.Scrollbox
|
|
* @see {@link https://davidfig.github.io/pixi-scrollbox/jsdoc/Scrollbox.html|Scrollbox}
|
|
* @see {@link https://davidfig.github.io/pixi-viewport/jsdoc/Viewport.html|Viewport}
|
|
*/
|
|
class Scrollview extends Scrollbox {
|
|
/**
|
|
* Creates an instance of a Scrollview.
|
|
*
|
|
* @constructor
|
|
* @see https://davidfig.github.io/pixi-scrollbox/jsdoc/Scrollbox.html
|
|
*/
|
|
constructor(opts = {}) {
|
|
super(opts);
|
|
|
|
this.opts = opts;
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Scrollview} A reference to the Scrollview for chaining.
|
|
*/
|
|
setup() {
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the Scrollview. Can be used after resizing.
|
|
*
|
|
* @return {Scrollview} A reference to the Scrollview for chaining.
|
|
*/
|
|
layout() {
|
|
this.update();
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Callback for the slider action onStart.
|
|
*
|
|
* @callback onStartCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Slider} slider - A reference to the slider (also this refers to the slider).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the slider action onUpdate.
|
|
*
|
|
* @callback onUpdateCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Slider} slider - A reference to the slider (also this refers to the slider).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the slider action onComplete.
|
|
*
|
|
* @callback onCompleteCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Slider} slider - A reference to the slider (also this refers to the slider).
|
|
*/
|
|
|
|
/**
|
|
* Class that represents a PixiJS Slider.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* // Create the slider
|
|
* const slider = new Slider({
|
|
* x: 10,
|
|
* y: 20
|
|
* })
|
|
*
|
|
* // Add the slider to a DisplayObject
|
|
* app.scene.addChild(slider)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/slider.html|DocTest}
|
|
*/
|
|
class Slider extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Slider.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the slider.
|
|
* @param {number} [opts.id=auto generated] - The id of the slider.
|
|
* @param {number} [opts.x=0] - The x position of the slider. Can be also set after creation with slider.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the slider. Can be also set after creation with slider.y = 0.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this slider. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.width=250] - The width of the slider.
|
|
* @param {number} [opts.height=2] - The height of the slider.
|
|
* @param {PIXI.DisplayObject} [opts.container=window.app|object] - The container where the slider events should be attached to.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the slider background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.controlFill=Theme.stroke] - The color of the slider control background as a hex value.
|
|
* @param {number} [opts.controlFillAlpha=Theme.strokeAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.controlStroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.controlStrokeWidth=Theme.strokeWidth * 0.8] - The width of the border in pixel.
|
|
* @param {number} [opts.controlStrokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.controlRadius=16] - The radius of the slider control.
|
|
* @param {boolean} [opts.disabled=false] - Is the slider disabled? When disabled, the slider has a lower alpha value
|
|
* and cannot be clicked (interactive is set to false).
|
|
* @param {onStartCallback} [opts.onStart] - Executed when the slider control starts to move.
|
|
* @param {onUpdateCallback} [opts.onUpdate] - Executed when the slider control is moved.
|
|
* @param {onCompleteCallback} [opts.onComplete] - Executed when the slider control was dropped.
|
|
* @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
|
|
* to display.
|
|
* @param {boolean} [opts.visible=true] - Is the slider initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
x: 0,
|
|
y: 0,
|
|
width: 250,
|
|
height: 2,
|
|
container: null,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
controlFill: theme.fill,
|
|
controlFillAlpha: 0.5,
|
|
controlStroke: theme.primaryColor,
|
|
controlStrokeWidth: 2,
|
|
controlStrokeAlpha: theme.strokeAlpha,
|
|
controlRadius: 16,
|
|
orientation: 'horizontal',
|
|
min: 0,
|
|
max: 100,
|
|
value: 0,
|
|
disabled: false,
|
|
onStart: null,
|
|
onUpdate: null,
|
|
onComplete: null,
|
|
tooltip: null,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.opts.container = this.opts.container || this;
|
|
|
|
// Validation
|
|
//-----------------
|
|
if (this.opts.height > this.opts.width) {
|
|
this.opts.height = this.opts.width;
|
|
}
|
|
|
|
if (this.opts.value < this.opts.min) {
|
|
this.opts.value = this.opts.min;
|
|
}
|
|
|
|
if (this.opts.value > this.opts.max) {
|
|
this.opts.value = this.opts.max;
|
|
}
|
|
|
|
// Properties
|
|
//-----------------
|
|
this.id = this.opts.id;
|
|
this.radius = this.opts.height / 2;
|
|
|
|
this._value = this.opts.value;
|
|
this._disabled = null;
|
|
|
|
this.sliderObj = null;
|
|
this.control = null;
|
|
this.tooltip = null;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
setup() {
|
|
// Container events
|
|
//-----------------
|
|
const container = this.opts.container;
|
|
|
|
this.on('pointermove', e => {
|
|
if (this.control.dragging) {
|
|
const moveX = this.control.event.data.getLocalPosition(this.control.parent).x;
|
|
this._value = this.pixelToValue(moveX - this.control.delta - this.opts.controlRadius);
|
|
let x = this.valueToPixel(this._value) + this.opts.controlRadius;
|
|
this.control.x = x;
|
|
|
|
if (this.opts.onUpdate) {
|
|
this.opts.onUpdate.call(this, e, this);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (container instanceof Element) {
|
|
container.addEventListener('pointerup', e => this.onEnd(e), false);
|
|
container.addEventListener('pointercancel', e => this.onEnd(e), false);
|
|
container.addEventListener('pointerleave', e => this.onEnd(e), false);
|
|
container.addEventListener('pointerout', e => this.onEnd(e), false);
|
|
container.addEventListener('mouseup', e => this.onEnd(e), false);
|
|
container.addEventListener('mousecancel', e => this.onEnd(e), false);
|
|
container.addEventListener('mouseleave', e => this.onEnd(e), false);
|
|
container.addEventListener('mouseout', e => this.onEnd(e), false);
|
|
} else {
|
|
container.interactive = true;
|
|
container.on('pointerup', e => this.onEnd(e));
|
|
container.on('pointercancel', e => this.onEnd(e));
|
|
container.on('pointerleave', e => this.onEnd(e));
|
|
container.on('pointerout', e => this.onEnd(e));
|
|
}
|
|
|
|
// Slider
|
|
//-----------------
|
|
let sliderObj = new PIXI.Graphics();
|
|
this.sliderObj = sliderObj;
|
|
this.addChild(sliderObj);
|
|
|
|
// Control
|
|
//-----------------
|
|
let control = new PIXI.Graphics();
|
|
control.x = this.opts.controlRadius + this.valueToPixel(this.opts.value);
|
|
control.y = this.opts.controlRadius;
|
|
|
|
// pointerdown on the control for dragndrop
|
|
control.on('pointerdown', e => {
|
|
control.event = e;
|
|
control.delta = e.data.getLocalPosition(this.control).x;
|
|
control.dragging = true;
|
|
|
|
if (this.opts.onStart) {
|
|
this.opts.onStart.call(this, e, this);
|
|
}
|
|
});
|
|
|
|
this.control = control;
|
|
|
|
this.addChild(this.control);
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.sliderObj.on('pointerover', e => {
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
|
|
});
|
|
|
|
this.sliderObj.on('pointerout', e => {
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 1 });
|
|
});
|
|
|
|
this.sliderObj.on('pointerdown', e => {
|
|
this.sliderObj.pointerdowned = true;
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.7 });
|
|
});
|
|
|
|
// Click on the slider bar
|
|
this.sliderObj.on('pointerup', e => {
|
|
if (this.sliderObj.pointerdowned) {
|
|
this.sliderObj.pointerdowned = false;
|
|
const position = e.data.getLocalPosition(this.control.parent);
|
|
this.value = this.pixelToValue(position.x - this.opts.controlRadius);
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
|
|
}
|
|
});
|
|
|
|
// disabled
|
|
//-----------------
|
|
this.disabled = this.opts.disabled;
|
|
|
|
// tooltip
|
|
//-----------------
|
|
if (this.opts.tooltip) {
|
|
if (typeof this.opts.tooltip === 'string') {
|
|
this.tooltip = new Tooltip({
|
|
object: this,
|
|
content: this.opts.tooltip
|
|
});
|
|
} else {
|
|
this.opts.tooltip.object = this;
|
|
this.tooltip = new Tooltip(this.opts.tooltip);
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the slider. Can be used after resizing.
|
|
*
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
layout() {
|
|
// set position
|
|
//-----------------
|
|
this.position.set(this.opts.x, this.opts.y);
|
|
|
|
// draw
|
|
//-----------------
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the slider to the canvas.
|
|
*
|
|
* @private
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
draw() {
|
|
const r = this.radius;
|
|
const cr = this.opts.controlRadius;
|
|
const w = this.opts.width;
|
|
const h = this.opts.height;
|
|
const x = cr + r;
|
|
const y = cr + r - h;
|
|
|
|
this.sliderObj.clear();
|
|
this.sliderObj.beginFill(0xffffff, 0);
|
|
this.sliderObj.drawRect(0, 0, x + w + cr, cr * 2);
|
|
this.sliderObj.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.sliderObj.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
this.sliderObj.moveTo(x, y);
|
|
this.sliderObj.lineTo(x + w, y);
|
|
this.sliderObj.arcTo(x + w + r, y, x + w + r, y + r, r);
|
|
this.sliderObj.lineTo(x + w + r, y + r + 1); // BUGFIX: If not specified, there is a small area without a stroke.
|
|
this.sliderObj.arcTo(x + w + r, y + h, x + w, y + h, r);
|
|
this.sliderObj.lineTo(x, y + h);
|
|
this.sliderObj.arcTo(x - r, y + h, x - r, y + r, r);
|
|
this.sliderObj.arcTo(x - r, y, x, y, r);
|
|
this.sliderObj.endFill();
|
|
|
|
// Draw control
|
|
this.control.clear();
|
|
this.control.lineStyle(this.opts.controlStrokeWidth, this.opts.controlStroke, this.opts.controlStrokeAlpha);
|
|
this.control.beginFill(this.opts.controlFill, this.opts.controlFillAlpha);
|
|
this.control.drawCircle(0, 0, cr - 1);
|
|
this.control.beginFill(this.opts.controlStroke, this.opts.controlStrokeAlpha);
|
|
this.control.drawCircle(0, 0, cr / 6);
|
|
this.control.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Executed, when the slider control movement ended.
|
|
*
|
|
* @private
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
onEnd(e) {
|
|
if (this.control.dragging) {
|
|
this.control.event = null;
|
|
this.control.dragging = false;
|
|
if (this.opts.onComplete) {
|
|
this.opts.onComplete.call(this, e, this);
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates the value for a given pixel.
|
|
*
|
|
* @private
|
|
* @param {number} value
|
|
* @returns {number} The calucalted pixel.
|
|
*/
|
|
valueToPixel(value) {
|
|
if (value < this.opts.min) {
|
|
value = this.opts.min;
|
|
} else if (value > this.opts.max) {
|
|
value = this.opts.max;
|
|
}
|
|
return (this.opts.width * (value - this.opts.min)) / (this.opts.max - this.opts.min)
|
|
}
|
|
|
|
/**
|
|
* Calculates the pixel for a given value.
|
|
*
|
|
* @private
|
|
* @param {number} pixel
|
|
* @returns {number} The calucalted value.
|
|
*/
|
|
pixelToValue(pixel) {
|
|
if (pixel < 0) {
|
|
pixel = 0;
|
|
} else if (pixel > this.opts.width) {
|
|
pixel = this.opts.width;
|
|
}
|
|
return this.opts.min + ((this.opts.max - this.opts.min) * pixel) / this.opts.width
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the value.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get value() {
|
|
return Math.round(this._value)
|
|
}
|
|
set value(value) {
|
|
if (value < this.opts.min) {
|
|
value = this.opts.min;
|
|
} else if (value > this.opts.max) {
|
|
value = this.opts.max;
|
|
}
|
|
this._value = value;
|
|
|
|
const x = this.valueToPixel(value) + this.opts.controlRadius;
|
|
|
|
TweenLite.to(this.control, this.theme.fast, { x });
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the disabled state. When disabled, the slider cannot be clicked.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get disabled() {
|
|
return this._disabled
|
|
}
|
|
set disabled(value) {
|
|
this._disabled = value;
|
|
|
|
if (this._disabled) {
|
|
this.interactive = false;
|
|
this.sliderObj.interactive = false;
|
|
this.control.interactive = false;
|
|
this.control.buttonMode = false;
|
|
this.alpha = 0.5;
|
|
} else {
|
|
this.interactive = true;
|
|
this.sliderObj.interactive = true;
|
|
this.control.interactive = true;
|
|
this.control.buttonMode = true;
|
|
this.alpha = 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows the slider (sets his alpha values to 1).
|
|
*
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
show() {
|
|
this.opts.strokeAlpha = 1;
|
|
this.opts.fillAlpha = 1;
|
|
this.opts.controlStrokeAlpha = 1;
|
|
this.opts.controlFillAlpha = 1;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the slider (sets his alpha values to 1).
|
|
*
|
|
* @return {Slider} A reference to the slider for chaining.
|
|
*/
|
|
hide() {
|
|
this.opts.strokeAlpha = 0;
|
|
this.opts.fillAlpha = 0;
|
|
this.opts.controlStrokeAlpha = 0;
|
|
this.opts.controlFillAlpha = 0;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-undef */
|
|
|
|
class StylusCommand extends Object {
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
do(stylus) {
|
|
stylus.commandStack.push(this);
|
|
}
|
|
|
|
undo(stylus) {
|
|
stylus.undoCommandStack.push(this);
|
|
}
|
|
|
|
redo(stylus) {
|
|
this.do(stylus);
|
|
}
|
|
}
|
|
|
|
class StrokeCommand extends StylusCommand {
|
|
constructor(stroke) {
|
|
super();
|
|
this.stroke = stroke;
|
|
}
|
|
|
|
do(stylus) {
|
|
if (this.stroke.length > 0) {
|
|
super.do(stylus);
|
|
stylus.stroke = [];
|
|
stylus.strokes.push(this.stroke);
|
|
stylus.redraw();
|
|
stylus.changed();
|
|
}
|
|
}
|
|
|
|
undo(stylus) {
|
|
if (this.stroke.length > 0) {
|
|
super.undo(stylus);
|
|
stylus.strokes.pop();
|
|
stylus.redraw();
|
|
stylus.changed();
|
|
}
|
|
}
|
|
}
|
|
|
|
class ClearCommand extends StylusCommand {
|
|
do(stylus) {
|
|
// Clears the command stack
|
|
stylus.commandStack = [];
|
|
super.do(stylus);
|
|
this.strokes = stylus.strokes;
|
|
stylus.stroke = [];
|
|
stylus.strokes = [];
|
|
stylus.redraw();
|
|
stylus.changed();
|
|
}
|
|
|
|
undo(stylus) {
|
|
//super.undo(stylus) // Clear all is not redoable
|
|
stylus.stroke = [];
|
|
stylus.strokes = this.strokes;
|
|
stylus.redraw();
|
|
stylus.changed();
|
|
}
|
|
}
|
|
|
|
class Stylus extends PIXI.Graphics {
|
|
constructor({
|
|
width = window.innerWidth,
|
|
height = window.innerHeight,
|
|
interactive = true,
|
|
color = 0x000000,
|
|
tiltX = 0,
|
|
tiltY = 0,
|
|
backgroundAlpha = 1,
|
|
backgroundFill = 0xffffff,
|
|
colorAlpha = 1,
|
|
captureEvents = true,
|
|
acceptMouseEvents = true
|
|
} = {}) {
|
|
super();
|
|
this.activePointers = 0;
|
|
this.wantedWidth = width;
|
|
this.wantedHeight = height;
|
|
this.backgroundAlpha = backgroundAlpha;
|
|
this.backgroundFill = backgroundFill;
|
|
this.colorAlpha = colorAlpha;
|
|
this.color = color;
|
|
this.interactive = interactive;
|
|
this.debug = false;
|
|
this.tiltX = tiltX; // degrees -90 ... 90
|
|
this.tiltY = tiltY; // degrees -90 ... 90
|
|
this.captureEvents = captureEvents;
|
|
this.commandStack = [];
|
|
this.undoCommandStack = [];
|
|
this.strokes = [];
|
|
this.stroke = [];
|
|
this.minStrokeLength = 4;
|
|
if (captureEvents) this.registerEventHandler(acceptMouseEvents);
|
|
this.drawBackground();
|
|
}
|
|
|
|
drawBackground() {
|
|
this.clear();
|
|
this.beginFill(this.backgroundFill, this.backgroundAlpha);
|
|
this.drawRect(0, 0, this.wantedWidth, this.wantedHeight);
|
|
this.endFill();
|
|
}
|
|
|
|
touchToPoint(t) {
|
|
return { x: t.clientX, y: t.clientY }
|
|
}
|
|
|
|
isStylusPointer(event) {
|
|
let identifier = event.data.identifier;
|
|
if (typeof event.data.originalEvent.changedTouches !== 'undefined') {
|
|
for (let touch of event.data.originalEvent.changedTouches) {
|
|
if (touch.identifier === identifier && touch.touchType === 'stylus') {
|
|
this.tiltX = Angle.radian2degree(touch.azimuthAngle);
|
|
this.tiltY = 90.0 - Angle.radian2degree(touch.altitudeAngle);
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
// UO: Not tested since the Sprot delivered "mouse" events to Chrome
|
|
if (event.data.originalEvent.pointerType === 'pen') {
|
|
this.tiltX = event.data.originalEvent.tiltX;
|
|
this.tiltY = event.data.originalEvent.tiltY;
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isStylusTouch(event) {
|
|
let identifier = event.data.identifier;
|
|
if (typeof event.data.originalEvent.changedTouches !== 'undefined') {
|
|
for (let touch of event.data.originalEvent.changedTouches) {
|
|
if (touch.identifier === identifier && touch.pointerType === 'touch') {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
getPointerID(event) {
|
|
let identifier = event.data.identifier;
|
|
for (let touch of event.data.originalEvent.changedTouches) {
|
|
if (touch.identifier === identifier) {
|
|
return touch.pointerId
|
|
}
|
|
}
|
|
}
|
|
|
|
singlePointer() {
|
|
return this.activePointers == 1
|
|
}
|
|
|
|
registerEventHandler() {
|
|
window.addEventListener('keydown', e => {
|
|
switch (e.keyCode) {
|
|
case 38: // up arrow
|
|
this.tiltX += 5;
|
|
break
|
|
case 40: // down arrow
|
|
this.tiltX -= 5;
|
|
break
|
|
case 37: // left arrow
|
|
this.tiltY -= 5;
|
|
break
|
|
case 39: // right arrow
|
|
this.tiltY += 5;
|
|
break
|
|
}
|
|
if (this.debug) console.log('keydown', e.keyCode, this.tiltX, this.tiltY);
|
|
});
|
|
|
|
this.on('pointerdown', e => {
|
|
if (this.debug) console.log('pointerdown', e);
|
|
if (this.eventInside(e)) {
|
|
this.activePointers += 1;
|
|
if (this.singlePointer()) {
|
|
this.startStroke(this.toStroke(e));
|
|
}
|
|
}
|
|
});
|
|
|
|
this.on('pointermove', e => {
|
|
if (Events$1.isPointerDown(e.data.originalEvent) || this.isStylusPointer(e) || this.isStylusTouch(e)) {
|
|
if (this.debug) console.log('pointermove', e, this.eventInside(e));
|
|
if (this.eventInside(e) && this.singlePointer()) this.moveStroke(this.toStroke(e));
|
|
}
|
|
});
|
|
this.on('pointerup', e => {
|
|
if (this.eventInside(e)) {
|
|
if (this.activePointers > 0) {
|
|
this.activePointers -= 1;
|
|
this.endStroke(this.toStroke(e));
|
|
}
|
|
}
|
|
if (this.debug) console.log('pointerup', this.activePointers);
|
|
});
|
|
this.on('pointerleave', e => {
|
|
if (this.activePointers > 0) {
|
|
this.activePointers -= 1;
|
|
}
|
|
this.endStroke(this.toStroke(e));
|
|
});
|
|
this.on('pointercancel', e => {
|
|
if (this.activePointers > 0) {
|
|
this.activePointers -= 1;
|
|
}
|
|
this.endStroke(this.toStroke(e));
|
|
});
|
|
}
|
|
|
|
undoable() {
|
|
return this.commandStack.length > 0
|
|
}
|
|
|
|
redoable() {
|
|
return this.undoCommandStack.length > 0
|
|
}
|
|
|
|
undo() {
|
|
if (this.undoable()) {
|
|
let cmd = this.commandStack.pop();
|
|
cmd.undo(this);
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this.redoable()) {
|
|
let cmd = this.undoCommandStack.pop();
|
|
cmd.redo(this);
|
|
}
|
|
}
|
|
|
|
eventInside(event) {
|
|
let local = this.toLocal(event.data.global);
|
|
for (let child of this.children) {
|
|
let r = child.getBounds();
|
|
if (r.contains(local.x, local.y)) {
|
|
console.log('Child touched');
|
|
return false
|
|
}
|
|
}
|
|
if (local.x < 0 || local.x > this.wantedWidth) return false
|
|
if (local.y < 0 || local.y > this.wantedHeight) return false
|
|
event.stopPropagation();
|
|
// if (this.debug) console.log('stopPropagation', event)
|
|
if (event.data.originalEvent.claimedByScatter) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
toLocalPoint(event) {
|
|
return this.toLocal(event.data.global)
|
|
}
|
|
|
|
toStroke(event) {
|
|
let local = this.toLocalPoint(event);
|
|
let x = Math.max(0, Math.min(local.x, this.wantedWidth));
|
|
let y = Math.max(0, Math.min(local.y, this.wantedHeight));
|
|
let desc = {
|
|
x,
|
|
y,
|
|
pressure: event.pressure || null,
|
|
tiltX: this.tiltX,
|
|
tiltY: this.tiltY,
|
|
color: this.color
|
|
};
|
|
return desc
|
|
}
|
|
|
|
startStroke(info) {
|
|
this.stroke = [info];
|
|
this.redraw();
|
|
}
|
|
|
|
moveStroke(info) {
|
|
this.stroke.push(info);
|
|
this.redraw();
|
|
}
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
endStroke(info) {
|
|
if (this.stroke.length >= this.minStrokeLength) {
|
|
let cmd = new StrokeCommand(this.stroke);
|
|
cmd.do(this);
|
|
}
|
|
}
|
|
|
|
tiltToLineWidth(value) {
|
|
return 16 //Math.round(Math.abs(value / 10) + 1)
|
|
}
|
|
|
|
drawStroke(stroke) {
|
|
if (stroke.length) {
|
|
let start = stroke[0];
|
|
this.beginFill(0, 0);
|
|
this.moveTo(start.x, start.y);
|
|
let lineWidth = this.tiltToLineWidth(start.tiltY);
|
|
this.lineStyle(lineWidth, start.color, this.colorAlpha);
|
|
for (let i = 1; i < stroke.length; i++) {
|
|
let info = stroke[i];
|
|
let lw = this.tiltToLineWidth(info.tiltY);
|
|
if (lw != lineWidth) {
|
|
lineWidth = lw;
|
|
this.lineStyle(lineWidth, info.color, this.colorAlpha);
|
|
}
|
|
this.lineTo(info.x, info.y);
|
|
}
|
|
this.endFill();
|
|
}
|
|
}
|
|
|
|
drawTouch(point) {
|
|
this.beginFill(0, 0);
|
|
this.drawCircle(point.x, point.y, 22);
|
|
this.endFill();
|
|
}
|
|
|
|
drawStrokes() {
|
|
this.drawBackground();
|
|
this.lineStyle(1.0, 0xff0000, 1);
|
|
for (let stroke of this.iterStrokes()) {
|
|
this.drawStroke(stroke);
|
|
}
|
|
}
|
|
|
|
redraw() {
|
|
this.drawStrokes();
|
|
}
|
|
|
|
// Can be overwritten if different levels of strokes are necessary
|
|
*iterStrokes() {
|
|
for (let stroke of this.strokes) {
|
|
yield stroke;
|
|
}
|
|
yield this.stroke;
|
|
}
|
|
|
|
changed() {
|
|
// Can be overwritten
|
|
}
|
|
|
|
clearAll() {
|
|
let cmd = new ClearCommand();
|
|
cmd.do(this);
|
|
}
|
|
|
|
normalizeInfo(info) {
|
|
let { x, y, pressure, tiltX, tiltY, color } = info;
|
|
x /= this.wantedWidth;
|
|
y /= this.wantedHeight;
|
|
return { x, y, pressure, tiltX, tiltY, color }
|
|
}
|
|
|
|
denormalizeInfo(info) {
|
|
let { x, y, pressure, tiltX, tiltY, color } = info;
|
|
x = x * this.wantedWidth;
|
|
y = y * this.wantedHeight;
|
|
return { x, y, pressure, tiltX, tiltY, color }
|
|
}
|
|
|
|
// Convert strokes into an object that can be stored in an Indexed DB.
|
|
// Returns normalized strokes
|
|
toObject() {
|
|
let result = [];
|
|
for (let stroke of this.strokes) {
|
|
let normalized = [];
|
|
for (let info of stroke) {
|
|
normalized.push(this.normalizeInfo(info));
|
|
}
|
|
result.push(normalized);
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Read normalized strokes from an object from an Indexed DB.
|
|
fromObject(normalizedStrokes) {
|
|
this.strokes = [];
|
|
for (let stroke of normalizedStrokes) {
|
|
let denormalized = [];
|
|
for (let info of stroke) {
|
|
denormalized.push(this.denormalizeInfo(info));
|
|
}
|
|
this.strokes.push(denormalized);
|
|
}
|
|
}
|
|
|
|
// Convert strokes into a JSON object that can be stored in an Indexed DB
|
|
toJSON() {
|
|
return JSON.stringify(this.toObject())
|
|
}
|
|
|
|
// Convert strokes from a JSON
|
|
fromJSON(json) {
|
|
this.fromObject(JSON.parse(json));
|
|
}
|
|
|
|
// Returns a set of used colors
|
|
usedColors() {
|
|
let used = new Set();
|
|
for (let info of this.stroke) {
|
|
used.add(info.color);
|
|
}
|
|
for (let stroke of this.strokes) {
|
|
for (let info of stroke) {
|
|
used.add(info.color);
|
|
}
|
|
}
|
|
return used.values()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Callback for the switch action.
|
|
*
|
|
* @callback actionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Switch} switch - A reference to the switch (also this refers to the switch).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the switch action.
|
|
*
|
|
* @callback actionActiveCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Switch} switch - A reference to the switch (also this refers to the switch).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the switch beforeAction.
|
|
*
|
|
* @callback beforeActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Switch} switch - A reference to the switch (also this refers to the switch).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the switch afterAction.
|
|
*
|
|
* @callback afterActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Switch} switch - A reference to the switch (also this refers to the switch).
|
|
*/
|
|
|
|
/**
|
|
* Class that represents a PixiJS Switch.
|
|
*
|
|
* @example
|
|
* // Create the app
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* // Create the switch
|
|
* const switch1 = new Switch({
|
|
* x: 10,
|
|
* y: 20
|
|
* })
|
|
*
|
|
* // Add the switch to a DisplayObject
|
|
* app.scene.addChild(switch1)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/switch.html|DocTest}
|
|
*/
|
|
class Switch extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Switch.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the switch.
|
|
* @param {number} [opts.id=auto generated] - The id of the switch.
|
|
* @param {number} [opts.x=0] - The x position of the switch. Can be also set after creation with switch.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the switch. Can be also set after creation with switch.y = 0.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this switch. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.width=44] - The width of the switch.
|
|
* @param {number} [opts.height=28] - The height of the switch.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the switch background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.fillActive=Theme.fillActive] - The color of the switch background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
|
|
* @param {number} [opts.controlFill=Theme.stroke] - The color of the switch control background as a hex value.
|
|
* @param {number} [opts.controlFillAlpha=Theme.strokeAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.controlFillActive=Theme.stroke] - The color of the switch control background when activated.
|
|
* @param {number} [opts.controlFillActiveAlpha=Theme.strokeAlpha] - The alpha value of the background when activated.
|
|
* @param {number} [opts.controlStroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.controlStrokeWidth=Theme.strokeWidth * 0.8] - The width of the border in pixel.
|
|
* @param {number} [opts.controlStrokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.controlStrokeActive=Theme.stroke] - The color of the border when activated.
|
|
* @param {number} [opts.controlStrokeActiveWidth=Theme.strokeActiveWidth * 0.8] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.controlStrokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
|
|
* @param {number} [opts.duration=Theme.fast] - The duration of the animation when the switch gets activated in seconds.
|
|
* @param {number} [opts.durationActive=Theme.fast] - The duration of the animation when the switch gets deactivated in seconds.
|
|
* @param {boolean} [opts.disabled=false] - Is the switch disabled? When disabled, the switch has a lower alpha value
|
|
* and cannot be clicked (interactive is set to false).
|
|
* @param {boolean} [opts.active=false] - Is the button initially active?
|
|
* @param {actionCallback} [opts.action] - Executed when the switch was triggered in inactive state (by pointerup).
|
|
* @param {actionActiveCallback} [opts.actionActive] - Executed when the button was triggered in active state (by pointerup).
|
|
* @param {beforeActionCallback} [opts.beforeAction] - Executed before an action is triggered.
|
|
* @param {afterActionCallback} [opts.afterAction] - Executed after an action was triggered.
|
|
* @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
|
|
* to display.
|
|
* @param {boolean} [opts.visible=true] - Is the switch initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
x: 0,
|
|
y: 0,
|
|
width: 44,
|
|
height: 28,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
fillActive: theme.primaryColor,
|
|
fillActiveAlpha: theme.fillActiveAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
strokeActive: theme.primaryColor,
|
|
strokeActiveWidth: theme.strokeActiveWidth,
|
|
strokeActiveAlpha: theme.strokeActiveAlpha,
|
|
controlFill: theme.stroke,
|
|
controlFillAlpha: theme.strokeAlpha,
|
|
controlFillActive: theme.stroke,
|
|
controlFillActiveAlpha: theme.strokeAlpha,
|
|
controlStroke: theme.stroke,
|
|
controlStrokeWidth: theme.strokeWidth * 0.8,
|
|
controlStrokeAlpha: theme.strokeAlpha,
|
|
controlStrokeActive: theme.stroke,
|
|
controlStrokeActiveWidth: theme.strokeActiveWidth * 0.8,
|
|
controlStrokeActiveAlpha: theme.strokeActiveAlpha,
|
|
duration: theme.fast,
|
|
durationActive: theme.fast,
|
|
disabled: false,
|
|
active: false,
|
|
action: null,
|
|
actionActive: null,
|
|
beforeAction: null,
|
|
afterAction: null,
|
|
tooltip: null,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.opts.controlRadius = this.opts.controlRadius || this.opts.height / 2;
|
|
this.opts.controlRadiusActive = this.opts.controlRadiusActive || this.opts.controlRadius;
|
|
|
|
// Validation
|
|
//-----------------
|
|
if (this.opts.height > this.opts.width) {
|
|
this.opts.height = this.opts.width;
|
|
}
|
|
|
|
// Properties
|
|
//-----------------
|
|
this.id = this.opts.id;
|
|
this.radius = this.opts.height / 2;
|
|
|
|
this._active = null;
|
|
this._disabled = null;
|
|
|
|
this.switchObj = null;
|
|
this.control = null;
|
|
this.tooltip = null;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
// animated
|
|
//-----------------
|
|
this.tempAnimated = {
|
|
fill: this.opts.fill,
|
|
fillAlpha: this.opts.fillAlpha,
|
|
stroke: this.opts.stroke,
|
|
strokeWidth: this.opts.strokeWidth,
|
|
strokeAlpha: this.opts.strokeAlpha,
|
|
controlFill: this.opts.controlFill,
|
|
controlFillAlpha: this.opts.controlFillAlpha,
|
|
controlStroke: this.opts.controlStroke,
|
|
controlStrokeWidth: this.opts.controlStrokeWidth,
|
|
controlStrokeAlpha: this.opts.controlStrokeAlpha,
|
|
controlRadius: this.opts.controlRadius
|
|
};
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
setup() {
|
|
// Switch
|
|
//-----------------
|
|
let switchObj = new PIXI.Graphics();
|
|
this.switchObj = switchObj;
|
|
this.addChild(switchObj);
|
|
|
|
// Control
|
|
//-----------------
|
|
this.xInactive = this.opts.controlRadius;
|
|
this.xActive = this.opts.width - this.opts.controlRadiusActive;
|
|
|
|
let control = new PIXI.Graphics();
|
|
control.x = this.opts.active ? this.xActive : this.xInactive;
|
|
control.y = this.opts.height / 2;
|
|
|
|
this.control = control;
|
|
|
|
this.addChild(this.control);
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.switchObj.on('pointerover', e => {
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
|
|
});
|
|
|
|
this.switchObj.on('pointerout', e => {
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 1 });
|
|
});
|
|
|
|
this.switchObj.on('pointerdown', e => {
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.7 });
|
|
});
|
|
|
|
this.switchObj.on('pointerup', e => {
|
|
if (this.opts.beforeAction) {
|
|
this.opts.beforeAction.call(this, e, this);
|
|
}
|
|
|
|
this.active = !this.active;
|
|
|
|
if (this.active) {
|
|
if (this.opts.action) {
|
|
this.opts.action.call(this, e, this);
|
|
}
|
|
} else {
|
|
if (this.opts.actionActive) {
|
|
this.opts.actionActive.call(this, e, this);
|
|
}
|
|
}
|
|
|
|
TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
|
|
|
|
if (this.opts.afterAction) {
|
|
this.opts.afterAction.call(this, e, this);
|
|
}
|
|
});
|
|
|
|
// disabled
|
|
//-----------------
|
|
this.disabled = this.opts.disabled;
|
|
|
|
// active
|
|
//-----------------
|
|
this.active = this.opts.active;
|
|
|
|
// tooltip
|
|
//-----------------
|
|
if (this.opts.tooltip) {
|
|
if (typeof this.opts.tooltip === 'string') {
|
|
this.tooltip = new Tooltip({
|
|
object: this,
|
|
content: this.opts.tooltip
|
|
});
|
|
} else {
|
|
this.opts.tooltip.object = this;
|
|
this.tooltip = new Tooltip(this.opts.tooltip);
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the switch. Can be used after resizing.
|
|
*
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
layout() {
|
|
// set position
|
|
//-----------------
|
|
this.position.set(this.opts.x, this.opts.y);
|
|
|
|
// draw
|
|
//-----------------
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the switch to the canvas.
|
|
*
|
|
* @private
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
draw() {
|
|
this.switchObj.clear();
|
|
if (this.active) {
|
|
this.switchObj.lineStyle(this.opts.strokeActiveWidth, this.opts.strokeActive, this.opts.strokeActiveAlpha);
|
|
this.switchObj.beginFill(this.opts.fillActive, this.opts.fillActiveAlpha);
|
|
} else {
|
|
this.switchObj.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.switchObj.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
}
|
|
this.switchObj.moveTo(this.radius, 0);
|
|
this.switchObj.lineTo(this.opts.width - this.radius, 0);
|
|
this.switchObj.arcTo(this.opts.width, 0, this.opts.width, this.radius, this.radius);
|
|
this.switchObj.lineTo(this.opts.width, this.radius + 1); // BUGFIX: If not specified, there is a small area without a stroke.
|
|
this.switchObj.arcTo(
|
|
this.opts.width,
|
|
this.opts.height,
|
|
this.opts.width - this.radius,
|
|
this.opts.height,
|
|
this.radius
|
|
);
|
|
this.switchObj.lineTo(this.radius, this.opts.height);
|
|
this.switchObj.arcTo(0, this.opts.height, 0, this.radius, this.radius);
|
|
this.switchObj.arcTo(0, 0, this.radius, 0, this.radius);
|
|
this.switchObj.endFill();
|
|
|
|
// Draw control
|
|
this.control.clear();
|
|
if (this.active) {
|
|
this.control.lineStyle(
|
|
this.opts.controlStrokeActiveWidth,
|
|
this.opts.controlStrokeActive,
|
|
this.opts.controlStrokeActiveAlpha
|
|
);
|
|
this.control.beginFill(this.opts.controlFillActive, this.opts.controlFillActiveAlpha);
|
|
this.control.drawCircle(0, 0, this.opts.controlRadiusActive - 1);
|
|
} else {
|
|
this.control.lineStyle(this.opts.controlStrokeWidth, this.opts.controlStroke, this.opts.controlStrokeAlpha);
|
|
this.control.beginFill(this.opts.controlFill, this.opts.controlFillAlpha);
|
|
this.control.drawCircle(0, 0, this.opts.controlRadius - 1);
|
|
}
|
|
this.control.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the animation.
|
|
*
|
|
* @private
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
drawAnimated() {
|
|
this.switchObj.clear();
|
|
this.switchObj.lineStyle(this.tempAnimated.strokeWidth, this.tempAnimated.stroke, this.tempAnimated.strokeAlpha);
|
|
this.switchObj.beginFill(this.tempAnimated.fill, this.tempAnimated.fillAlpha);
|
|
this.switchObj.moveTo(this.radius, 0);
|
|
this.switchObj.lineTo(this.opts.width - this.radius, 0);
|
|
this.switchObj.arcTo(this.opts.width, 0, this.opts.width, this.radius, this.radius);
|
|
this.switchObj.lineTo(this.opts.width, this.radius + 1); // BUGFIX: If not specified, there is a small area without a stroke.
|
|
this.switchObj.arcTo(
|
|
this.opts.width,
|
|
this.opts.height,
|
|
this.opts.width - this.radius,
|
|
this.opts.height,
|
|
this.radius
|
|
);
|
|
this.switchObj.lineTo(this.radius, this.opts.height);
|
|
this.switchObj.arcTo(0, this.opts.height, 0, this.radius, this.radius);
|
|
this.switchObj.arcTo(0, 0, this.radius, 0, this.radius);
|
|
this.switchObj.endFill();
|
|
|
|
this.control.clear();
|
|
this.control.lineStyle(
|
|
this.tempAnimated.controlStrokeWidth,
|
|
this.tempAnimated.controlStroke,
|
|
this.tempAnimated.controlStrokeAlpha
|
|
);
|
|
this.control.beginFill(this.tempAnimated.controlFill, this.tempAnimated.controlFillAlpha);
|
|
this.control.drawCircle(0, 0, this.tempAnimated.controlRadius - 1);
|
|
this.control.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the active state.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get active() {
|
|
return this._active
|
|
}
|
|
|
|
set active(value) {
|
|
this._active = value;
|
|
|
|
if (this._active) {
|
|
TweenLite.to(this.control, this.opts.duration, { x: this.xActive });
|
|
TweenLite.to(this.tempAnimated, this.opts.duration, {
|
|
colorProps: {
|
|
fill: this.opts.fillActive,
|
|
stroke: this.opts.strokeActive,
|
|
controlFill: this.opts.controlFillActive,
|
|
controlStroke: this.opts.controlStrokeActive,
|
|
format: 'number'
|
|
},
|
|
fillAlpha: this.opts.fillActiveAlpha,
|
|
strokeWidth: this.opts.strokeActiveWidth,
|
|
strokeAlpha: this.opts.strokeActiveAlpha,
|
|
controlFillAlpha: this.opts.controlFillActiveAlpha,
|
|
controlStrokeWidth: this.opts.controlStrokeActiveWidth,
|
|
controlStrokeAlpha: this.opts.controlStrokeActiveAlpha,
|
|
controlRadius: this.opts.controlRadiusActive,
|
|
onUpdate: () => this.drawAnimated(),
|
|
onComplete: () => this.draw()
|
|
});
|
|
} else {
|
|
TweenLite.to(this.control, this.opts.durationActive, {
|
|
x: this.xInactive
|
|
});
|
|
TweenLite.to(this.tempAnimated, this.opts.durationActive, {
|
|
colorProps: {
|
|
fill: this.opts.fill,
|
|
stroke: this.opts.stroke,
|
|
controlFill: this.opts.controlFill,
|
|
controlStroke: this.opts.controlStroke,
|
|
format: 'number'
|
|
},
|
|
fillAlpha: this.opts.fillAlpha,
|
|
strokeWidth: this.opts.strokeWidth,
|
|
strokeAlpha: this.opts.strokeAlpha,
|
|
controlFillAlpha: this.opts.controlFillAlpha,
|
|
controlStrokeWidth: this.opts.controlStrokeWidth,
|
|
controlStrokeAlpha: this.opts.controlStrokeAlpha,
|
|
controlRadius: this.opts.controlRadius,
|
|
onUpdate: () => this.drawAnimated(),
|
|
onComplete: () => this.draw()
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the disabled state. When disabled, the switch cannot be clicked.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get disabled() {
|
|
return this._disabled
|
|
}
|
|
|
|
set disabled(value) {
|
|
this._disabled = value;
|
|
|
|
if (this._disabled) {
|
|
this.switchObj.interactive = false;
|
|
this.switchObj.buttonMode = false;
|
|
this.switchObj.alpha = 0.5;
|
|
this.control.alpha = 0.5;
|
|
} else {
|
|
this.switchObj.interactive = true;
|
|
this.switchObj.buttonMode = true;
|
|
this.switchObj.alpha = 1;
|
|
this.control.alpha = 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows the switch (sets his alpha values to 1).
|
|
*
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
show() {
|
|
this.opts.strokeAlpha = 1;
|
|
this.opts.strokeActiveAlpha = 1;
|
|
this.opts.fillAlpha = 1;
|
|
this.opts.fillActiveAlpha = 1;
|
|
this.opts.controlStrokeAlpha = 1;
|
|
this.opts.controlStrokeActiveAlpha = 1;
|
|
this.opts.controlFillAlpha = 1;
|
|
this.opts.controlFillActiveAlpha = 1;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the switch (sets his alpha values to 1).
|
|
*
|
|
* @return {Switch} A reference to the switch for chaining.
|
|
*/
|
|
hide() {
|
|
this.opts.strokeAlpha = 0;
|
|
this.opts.strokeActiveAlpha = 0;
|
|
this.opts.fillAlpha = 0;
|
|
this.opts.fillActiveAlpha = 0;
|
|
this.opts.controlStrokeAlpha = 0;
|
|
this.opts.controlStrokeActiveAlpha = 0;
|
|
this.opts.controlFillAlpha = 0;
|
|
this.opts.controlFillActiveAlpha = 0;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class that represents a PixiJS PopupMenu.
|
|
*
|
|
* @example
|
|
* // Create the button and the modal when clicked
|
|
* const button = new Button({
|
|
* label: 'Show PopupMenu',
|
|
* action: e => {
|
|
* const popupmenu = new PopupMenu({
|
|
* items: [
|
|
* {label: 'Save', action: () => alert('Saved')},
|
|
* {label: 'Edit', action: () => alert('Edited')},
|
|
* {label: 'Delete', action: () => alert('Deleted')}
|
|
* ]
|
|
* })
|
|
* app.scene.addChild(popupmenu)
|
|
* }
|
|
* })
|
|
*
|
|
* // Add the button to a DisplayObject
|
|
* app.scene.addChild(button)
|
|
*
|
|
* @class
|
|
* @extends Popup
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/popupmenu.html|DocTest}
|
|
*/
|
|
class PopupMenu extends Popup {
|
|
/**
|
|
* Creates an instance of a PopupMenu.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the modal.
|
|
* @param {object[]} [opts.items=[]] - A list of the menu items. Each item must be of type object.
|
|
* If an object has a label property, a PIXI.Text object is created (using the textStyle property).
|
|
* If an object hasn't a label property, it must contain a content property which has to be a
|
|
* PIXI.DisplayObject.
|
|
* @param {number} [opts.margin=Theme.margin / 2] - The app where the modal belongs to.
|
|
* @param {object} [opts.textStyle=Theme.textStyle] - The color of the background.
|
|
* @param {boolean} [opts.closeOnPopup=true] - The opacity of the background.
|
|
*/
|
|
constructor(opts = {}) {
|
|
const theme = Theme.fromString(opts.theme);
|
|
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
items: [],
|
|
margin: theme.margin / 2,
|
|
textStyle: theme.textStyle,
|
|
closeOnPopup: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
super(opts);
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {PopupMenu} A reference to the popupmenu for chaining.
|
|
*/
|
|
setup() {
|
|
// content
|
|
//-----------------
|
|
const content = new PIXI.Container();
|
|
|
|
let y = 0;
|
|
for (let item of this.opts.items) {
|
|
let object = null;
|
|
|
|
if (item.label) {
|
|
object = new PIXI.Text(item.label, item.textStyle || this.opts.textStyle);
|
|
} else {
|
|
object = item.content;
|
|
}
|
|
|
|
object.y = y;
|
|
|
|
if (item.action) {
|
|
if (item.disabled) {
|
|
object.alpha = 0.5;
|
|
} else {
|
|
object.interactive = true;
|
|
object.buttonMode = true;
|
|
}
|
|
object.on('pointerover', e => {
|
|
TweenLite.to(object, this.theme.fast, {
|
|
alpha: 0.83,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
object.on('pointerout', e => {
|
|
TweenLite.to(object, this.theme.fast, {
|
|
alpha: 1,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
object.on('pointerup', e => {
|
|
item.action.call(object, e, object);
|
|
if (this.opts.closeOnAction) {
|
|
this.hide();
|
|
}
|
|
});
|
|
}
|
|
|
|
content.addChild(object);
|
|
|
|
y += object.height + this.opts.margin;
|
|
}
|
|
|
|
this.opts.content = content;
|
|
|
|
super.setup();
|
|
}
|
|
}
|
|
|
|
/* global Quad */
|
|
|
|
/**
|
|
* Class that represents a PixiJS Volatile.
|
|
*
|
|
* @example
|
|
* const app = new PIXIApp({
|
|
* view: canvas,
|
|
* width: 900,
|
|
* height: 250
|
|
* }).setup().run()
|
|
*
|
|
* const button = new Button({
|
|
* label: 'Volatile!',
|
|
* action: () => {
|
|
* new Volatile({
|
|
* object: button,
|
|
* direction: 'right',
|
|
* destroyOnComplete: false
|
|
* })
|
|
* }
|
|
* })
|
|
*
|
|
* app.scene.addChild(button)
|
|
*
|
|
* @class
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/volatile.html|DocTest}
|
|
*/
|
|
class Volatile {
|
|
/**
|
|
* Creates an instance of a Volatile.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the modal.
|
|
* @param {number} [opts.id=auto generated] - The id of the tooltip.
|
|
* @param {PIXI.DisplayObject|PIXI.DisplayObject[]} opts.object - The object where the volatile should be applied to.
|
|
* @param {string} [opts.direction=top] - The animation direction. Possible values: top, right, bottom, left.
|
|
* @param {function} [opts.onStart] - A function which is executed when the volatile animation starts.
|
|
* @param {function} [opts.onComplete] - A function which is executed when the volatile animation finishes.
|
|
* @param {number} [opts.distance=160] - The animation distance.
|
|
* @param {number} [opts.duration=1.5] - The duration of the animation in seconds.
|
|
* @param {object} [opts.ease=Quad.easeOut] - The easing of the animation, see {@link https://greensock.com/docs/Easing}
|
|
* @param {boolean} [opts.destroyOnComplete=true] - Should the object be destroyed after the volatile animation?
|
|
*/
|
|
constructor(opts = {}) {
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
object: null,
|
|
direction: 'top', // top, right, bottom, left
|
|
onStart: null,
|
|
onComplete: null,
|
|
distance: 160,
|
|
duration: 1.5,
|
|
ease: Quad.easeOut,
|
|
destroyOnComplete: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
if (!Array.isArray(this.opts.object)) {
|
|
this.opts.object = [this.opts.object];
|
|
}
|
|
|
|
this.objects = this.opts.object;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
|
|
// layout
|
|
//-----------------
|
|
this.layout();
|
|
|
|
// run
|
|
//-----------------
|
|
this.run();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Volatile} A reference to the volatile for chaining.
|
|
*/
|
|
setup() {
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the volatile. Can be used after resizing.
|
|
*
|
|
* @return {Volatile} A reference to the volatile for chaining.
|
|
*/
|
|
layout() {
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Starts the volatile animation.
|
|
*
|
|
* @private
|
|
* @return {Volatile} A reference to the volatile for chaining.
|
|
*/
|
|
run() {
|
|
for (let object of this.objects) {
|
|
let x = object.x;
|
|
let y = object.y;
|
|
|
|
switch (this.opts.direction) {
|
|
case 'top':
|
|
y -= this.opts.distance;
|
|
break
|
|
case 'right':
|
|
x += this.opts.distance;
|
|
break
|
|
case 'bottom':
|
|
y += this.opts.distance;
|
|
break
|
|
case 'left':
|
|
x -= this.opts.distance;
|
|
break
|
|
}
|
|
|
|
TweenLite.to(object, this.opts.duration, {
|
|
x,
|
|
y,
|
|
alpha: 0,
|
|
ease: this.opts.ease,
|
|
overwrite: 'all',
|
|
onStart: () => {
|
|
if (this.opts.onStart) {
|
|
this.opts.onStart.call(object, object);
|
|
}
|
|
},
|
|
onComplete: () => {
|
|
if (this.opts.onComplete) {
|
|
this.opts.onComplete.call(object, object);
|
|
}
|
|
|
|
if (this.opts.destroyOnComplete) {
|
|
object.destroy({ children: true });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
/* globals ThrowPropsPlugin, Strong */
|
|
|
|
/**
|
|
* Class that represents a PixiJS List.
|
|
*
|
|
* @example
|
|
* const elephant1 = PIXI.Sprite.from('./assets/elephant-1.jpg')
|
|
* const elephant2 = PIXI.Sprite.from('./assets/elephant-2.jpg')
|
|
*
|
|
* // Create the list
|
|
* const list = new List([elephant1, elephant2])
|
|
*
|
|
* app.scene.addChild(list)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PixiJS Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/list.html|DocTest}
|
|
*/
|
|
class List extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Flippable.
|
|
*
|
|
* @constructor
|
|
* @param {PIXI.DisplayObject[]} items - An array of PIXI.DisplayObjects.
|
|
* @param {object} [opts] - An options object which can contain the following properties.
|
|
* @param {number} [opts.width] - The width of the list. If the items are larger than this width, the overflow
|
|
* will be hidden.
|
|
* @param {number} [opts.height] - The height of the list. If the items are larger than this height, the overflow
|
|
* will be hidden.
|
|
* @param {number} [opts.padding=10] - The inner spacing (distance from one item to the previous/next item).
|
|
* @param {number} [opts.margin=10] - The outer spacing (distance from one item to the border).
|
|
* @param {string} [opts.orientation=vertical] - The orientation of the button group. Can be horizontal or vertical.
|
|
* @param {string} [opts.align=left] - The horizontal position of the items. Possible values are
|
|
* left, center and right.
|
|
* @param {string} [opts.verticalAlign=middle] - The vertical position of the items. Possible values are
|
|
* top, middle and bottom.
|
|
* @param {PIXI.Application} [opts.app] - The PixiJS Application. Must be set if you want to use the mousewheel to
|
|
* scroll your list.
|
|
*/
|
|
constructor(items = [], opts = {}) {
|
|
super();
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
padding: 10,
|
|
margin: 10,
|
|
orientation: 'vertical',
|
|
align: 'left',
|
|
verticalAlign: 'middle',
|
|
width: null,
|
|
height: null,
|
|
app: null
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.__items = items;
|
|
this.__dragging = false;
|
|
|
|
// setup
|
|
//--------------------
|
|
this.setup();
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {List} A reference to the list for chaining.
|
|
*/
|
|
setup() {
|
|
// inner container
|
|
//--------------------
|
|
const container = new PIXI.Container();
|
|
this.addChild(container);
|
|
this.container = container;
|
|
|
|
// mask
|
|
//--------------------
|
|
const mask = new PIXI.Graphics();
|
|
this.addChild(mask);
|
|
this.__mask = mask;
|
|
|
|
// add items
|
|
//--------------------
|
|
for (let item of this.__items) {
|
|
container.addChild(item);
|
|
}
|
|
|
|
// interaction
|
|
//--------------------
|
|
this.interactive = this.opts.width || this.opts.height;
|
|
this.on('pointerdown', this.onStart.bind(this));
|
|
this.on('pointermove', this.onMove.bind(this));
|
|
this.on('pointerup', this.onEnd.bind(this));
|
|
this.on('pointercancel', this.onEnd.bind(this));
|
|
this.on('pointerout', this.onEnd.bind(this));
|
|
this.on('pointerupoutside', this.onEnd.bind(this));
|
|
this.on('scroll', this.onScroll.bind(this));
|
|
|
|
// mousewheel
|
|
//--------------------
|
|
if (this.opts.app) {
|
|
const app = this.opts.app;
|
|
app.view.addEventListener('mousewheel', event => {
|
|
const bounds = this.mask ? this.mask.getBounds() : this.getBounds();
|
|
const x = event.clientX - app.view.getBoundingClientRect().left;
|
|
const y = event.clientY - app.view.getBoundingClientRect().top;
|
|
if (bounds.contains(x, y)) {
|
|
event.preventDefault();
|
|
this.emit('scroll', event);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Replaces the existing items and relayouts the list.
|
|
*
|
|
* @param {PIXI.DisplayObject[]} items - An array of PIXI.DisplayObjects.
|
|
* @return {List} A reference to the list for chaining.
|
|
*/
|
|
setItems(items) {
|
|
this.container.removeChildren();
|
|
this.__items = items;
|
|
for (let item of this.__items) {
|
|
this.container.addChild(item);
|
|
}
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the list (the width or the height).
|
|
*
|
|
* @return {List} A reference to the list for chaining.
|
|
*/
|
|
layout() {
|
|
const margin = this.opts.margin;
|
|
|
|
let x = margin;
|
|
let y = margin;
|
|
|
|
for (let item of this.__items) {
|
|
item.x = x;
|
|
item.y = y;
|
|
|
|
if (this.opts.orientation === 'vertical') {
|
|
y += item.height + this.opts.padding;
|
|
} else {
|
|
x += item.width + this.opts.padding;
|
|
}
|
|
}
|
|
|
|
// vertical
|
|
//--------------------
|
|
if (this.opts.orientation === 'vertical') {
|
|
switch (this.opts.align) {
|
|
case 'center':
|
|
this.__items.forEach(it => (it.x = margin + this.width / 2 - it.width / 2));
|
|
break
|
|
case 'right':
|
|
this.__items.forEach(it => (it.x = margin + this.width - it.width));
|
|
break
|
|
default:
|
|
this.__items.forEach(it => (it.x = margin));
|
|
break
|
|
}
|
|
|
|
if (this.opts.height) {
|
|
const mask = this.__mask;
|
|
mask.clear();
|
|
mask.beginFill(0x000);
|
|
mask.drawRect(0, 0, this.width + 2 * margin, this.opts.height);
|
|
this.mask = mask;
|
|
|
|
this.interactive = this.innerHeight > this.opts.height;
|
|
}
|
|
}
|
|
|
|
// horizontal
|
|
//--------------------
|
|
if (this.opts.orientation === 'horizontal') {
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
this.__items.forEach(it => (it.y = margin));
|
|
break
|
|
case 'bottom':
|
|
this.__items.forEach(it => (it.y = margin + this.height - it.height));
|
|
break
|
|
default:
|
|
this.__items.forEach(it => (it.y = margin + this.height / 2 - it.height / 2));
|
|
break
|
|
}
|
|
|
|
if (this.opts.width) {
|
|
const mask = this.__mask;
|
|
mask.clear();
|
|
mask.beginFill(0x000);
|
|
mask.drawRect(0, 0, this.opts.width, this.height + 2 * margin);
|
|
this.mask = mask;
|
|
|
|
this.interactive = this.innerWidth > this.opts.width;
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get innerWidth() {
|
|
let size = 0;
|
|
|
|
this.__items.forEach(it => (size += it.width));
|
|
size += this.opts.padding * (this.__items.length - 1);
|
|
size += 2 * this.opts.margin;
|
|
|
|
return size
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get innerHeight() {
|
|
let size = 0;
|
|
|
|
this.__items.forEach(it => (size += it.height));
|
|
size += this.opts.padding * (this.__items.length - 1);
|
|
size += 2 * this.opts.margin;
|
|
|
|
return size
|
|
}
|
|
|
|
/**
|
|
* Resizes the list.
|
|
*
|
|
* @param {number} widthOrHeight - The new width (if orientation is horizontal) or height (if orientation is vertical) of the list.
|
|
*/
|
|
resize(widthOrHeight) {
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.opts.width = widthOrHeight;
|
|
} else {
|
|
this.opts.height = widthOrHeight;
|
|
}
|
|
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onStart(event) {
|
|
this.__dragging = true;
|
|
|
|
this.capture(event);
|
|
|
|
this.__delta = {
|
|
x: this.container.position.x - event.data.global.x,
|
|
y: this.container.position.y - event.data.global.y
|
|
};
|
|
|
|
TweenLite.killTweensOf(this.container.position, { x: true, y: true });
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
|
ThrowPropsPlugin.track(this.container.position, 'x,y');
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onMove(event) {
|
|
if (this.__dragging) {
|
|
this.capture(event);
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.container.position.x = event.data.global.x + this.__delta.x;
|
|
} else {
|
|
this.container.position.y = event.data.global.y + this.__delta.y;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onEnd(event) {
|
|
if (this.__dragging) {
|
|
this.__dragging = false;
|
|
|
|
this.capture(event);
|
|
|
|
const throwProps = {};
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
let min = this.opts.width - this.innerWidth;
|
|
min = min > 0 ? 0 : min;
|
|
throwProps.x = {
|
|
velocity: 'auto',
|
|
min,
|
|
max: 0
|
|
};
|
|
} else {
|
|
let min = this.opts.height - this.innerHeight;
|
|
min = min > 0 ? 0 : min;
|
|
throwProps.y = {
|
|
velocity: 'auto',
|
|
min,
|
|
max: 0
|
|
};
|
|
}
|
|
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
|
ThrowPropsPlugin.to(
|
|
this.container.position,
|
|
{
|
|
throwProps,
|
|
ease: Strong.easeOut,
|
|
onComplete: () => ThrowPropsPlugin.untrack(this.container.position)
|
|
},
|
|
0.8,
|
|
0.4
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} event
|
|
*/
|
|
onScroll(event) {
|
|
this.capture(event);
|
|
|
|
if (this.opts.orientation === 'horizontal') {
|
|
this.container.position.x -= event.deltaX;
|
|
if (this.container.position.x > 0) {
|
|
this.container.position.x = 0;
|
|
} else if (this.container.position.x + this.innerWidth < this.opts.width) {
|
|
this.container.position.x = this.opts.width - this.innerWidth;
|
|
}
|
|
} else {
|
|
this.container.position.y -= event.deltaY;
|
|
if (this.container.position.y > 0) {
|
|
this.container.position.y = 0;
|
|
} else if (this.container.position.y + this.innerHeight < this.opts.height) {
|
|
this.container.position.y = this.opts.height - this.innerHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Captures an event to inform InteractionMapper about processed events.
|
|
*
|
|
* @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
|
|
*/
|
|
capture(event) {
|
|
const originalEvent = event.data && event.data.originalEvent ? event.data.originalEvent : event;
|
|
Events$1.capturedBy(originalEvent, this);
|
|
}
|
|
}
|
|
|
|
/* global */
|
|
|
|
/**
|
|
* Callback for the button action.
|
|
*
|
|
* @callback actionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the button beforeAction.
|
|
*
|
|
* @callback beforeActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Callback for the button afterAction.
|
|
*
|
|
* @callback afterActionCallback
|
|
* @param {object} event - The event object.
|
|
* @param {Button} button - A reference to the button (also this refers to the button).
|
|
*/
|
|
|
|
/**
|
|
* Class that represents a PixiJS Text.
|
|
*
|
|
* @example
|
|
* // Create the text
|
|
* const text = new Text({
|
|
* label: 'My Text',
|
|
* action: () => console.log('Text was clicked')
|
|
* })
|
|
*
|
|
* // Add the text to a DisplayObject
|
|
* app.scene.addChild(text)
|
|
*
|
|
* @class
|
|
* @extends PIXI.Container
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/text.html|DocTest}
|
|
*/
|
|
class Text extends PIXI.Container {
|
|
/**
|
|
* Creates an instance of a Button.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify to style and behaviour of the button.
|
|
* @param {number} [opts.id=auto generated] - The id of the button.
|
|
* @param {string} [opts.label] - The label of the button.
|
|
* @param {number} [opts.x=0] - The x position of the button. Can be also set after creation with button.x = 0.
|
|
* @param {number} [opts.y=0] - The y position of the button. Can be also set after creation with button.y = 0.
|
|
* @param {string|Theme} [opts.theme=dark] - The theme to use for this button. Possible values are dark, light, red
|
|
* or a Theme object.
|
|
* @param {number} [opts.minWidth=44] - The minimum width of the button.
|
|
* @param {number} [opts.minHeight=44] - The minimum height of the button.
|
|
* @param {number} [opts.padding=Theme.padding] - The inner spacing (distance from icon and/or label) to the border.
|
|
* @param {string|PIXI.DisplayObject} [opts.icon] - The icon of the button. Can be a predefined one, an URL or an PIXI.DisplayObject.
|
|
* @param {string|PIXI.DisplayObject} [opts.iconActive=icon] - The icon of the button when activated. Can be a predefined one, an URL or an PIXI.DisplayObject.
|
|
* @param {string} [opts.iconPosition=left] - The position of the icon in relation to the label. Can be left or right.
|
|
* @param {number} [opts.iconColor=Theme.iconColor] - The color of the icon (set by the tint property) as a hex value.
|
|
* @param {number} [opts.iconColorActive=Theme.iconColorActive] - The color of the icon when activated.
|
|
* @param {number} [opts.fill=Theme.fill] - The color of the button background as a hex value.
|
|
* @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
|
|
* @param {number} [opts.fillActive=Theme.fillActive] - The color of the button background when activated.
|
|
* @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
|
|
* @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
|
|
* @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
|
|
* @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
|
|
* @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
|
|
* @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - The width of the border in pixel when activated.
|
|
* @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
|
|
* @param {object} [opts.textStyle=Theme.textStyle] - A textstyle object for the styling of the label. See PIXI.TextStyle
|
|
* for possible options.
|
|
* @param {number} [opts.textStyleActive=Theme.textStyleActive] - A textstyle object for the styling of the label when the
|
|
* button is activated. See PIXI.TextStyle for possible options.
|
|
* @param {string} [opts.style=default] - A shortcut for styling options. Possible values are default, link.
|
|
* @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the button (which is a rounded rectangle).
|
|
* @param {boolean} [opts.disabled=false] - Is the button disabled? When disabled, the button has a lower alpha value
|
|
* and cannot be clicked (interactive is set to false).
|
|
* @param {boolean} [opts.active=false] - Is the button initially active?
|
|
* @param {actionCallback} [opts.action] - Executed when the button was triggered (by pointerup).
|
|
* @param {beforeActionCallback} [opts.beforeAction] - Executed before the main action is triggered.
|
|
* @param {afterActionCallback} [opts.afterAction] - Executed after the main action was triggered.
|
|
* @param {string} [opts.type=default] - The type of the button. Can be default or checkbox. When the type is
|
|
* checkbox, the active state is toggled automatically.
|
|
* @param {string} [opts.align=center] - The horizontal position of the label and the icon. Possible values are
|
|
* left, center and right. Only affects the style when the minWidth is bigger than the width of the icon and label.
|
|
* @param {string} [opts.verticalAlign=middle] - The vertical position of the label and the icon. Possible values are
|
|
* top, middle and button. Only affects the style when the minHeight is bigger than the height of the icon and label.
|
|
* @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
|
|
* to display.
|
|
* @param {string|object} [opts.badge] - A string for the label of the badge or an object to configure the badge to display.
|
|
* If the parameter is an object, all badge options can be set plus the following:
|
|
* @param {string} [opts.badge.align=right] - The horizontal alignment of the badge. Possible values: left, center, right
|
|
* @param {string} [opts.badge.verticalAlign=top] - The vertical alignment of the badge. Possible values: top, middle, bottom
|
|
* @param {number} [opts.badge.offsetLeft=0] - The horizontal shift of the badge.
|
|
* @param {number} [opts.badge.offsetTop=0] - The vertical shift of the badge.
|
|
* @param {boolean} [opts.visible=true] - Is the button initially visible (property visible)?
|
|
*/
|
|
constructor(opts = {}) {
|
|
super();
|
|
|
|
const theme = Theme.fromString(opts.theme);
|
|
this.theme = theme;
|
|
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
id: PIXI.utils.uid(),
|
|
label: null,
|
|
x: 0,
|
|
y: 0,
|
|
minWidth: 44,
|
|
minHeight: 44,
|
|
padding: theme.padding,
|
|
icon: undefined,
|
|
iconActive: undefined,
|
|
iconPosition: 'left',
|
|
iconColor: theme.iconColor,
|
|
iconColorActive: theme.iconColorActive,
|
|
fill: theme.fill,
|
|
fillAlpha: theme.fillAlpha,
|
|
fillActive: theme.fillActive,
|
|
fillActiveAlpha: theme.fillActiveAlpha,
|
|
stroke: theme.stroke,
|
|
strokeWidth: theme.strokeWidth,
|
|
strokeAlpha: theme.strokeAlpha,
|
|
strokeActive: theme.strokeActive,
|
|
strokeActiveWidth: theme.strokeActiveWidth,
|
|
strokeActiveAlpha: theme.strokeActiveAlpha,
|
|
textStyle: {},
|
|
textStyleActive: {},
|
|
style: 'default',
|
|
radius: theme.radius,
|
|
disabled: false,
|
|
active: false,
|
|
action: null,
|
|
beforeAction: null,
|
|
afterAction: null,
|
|
type: 'default',
|
|
align: 'center',
|
|
verticalAlign: 'middle',
|
|
tooltip: null,
|
|
badge: null,
|
|
visible: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.id = this.opts.id;
|
|
|
|
this.opts.textStyle = Object.assign({}, theme.textStyle, this.opts.textStyle);
|
|
this.opts.textStyleActive = Object.assign({}, theme.textStyleActive, this.opts.textStyleActive);
|
|
|
|
if (typeof this.opts.icon === 'undefined' && typeof this.opts.iconActive !== 'undefined') {
|
|
this.opts.icon = this.opts.iconActive;
|
|
} else if (typeof this.opts.icon !== 'undefined' && typeof this.opts.iconActive === 'undefined') {
|
|
this.opts.iconActive = this.opts.icon;
|
|
}
|
|
|
|
if (this.opts.style === 'link') {
|
|
Object.assign(this.opts, {
|
|
strokeAlpha: 0,
|
|
strokeActiveAlpha: 0,
|
|
fillAlpha: 0,
|
|
fillActiveAlpha: 0
|
|
});
|
|
}
|
|
|
|
this._active = null;
|
|
this._disabled = null;
|
|
|
|
this.__start = { x: null, y: null };
|
|
|
|
this.iconInactive = null;
|
|
this.iconActive = null;
|
|
this.text = null;
|
|
|
|
this.button = null;
|
|
this.content = null;
|
|
|
|
this.tooltip = null;
|
|
this.badge = null;
|
|
|
|
this.visible = this.opts.visible;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
}
|
|
|
|
/**
|
|
* Captures an event to inform InteractionMapper about processed events.
|
|
*
|
|
* @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
|
|
*/
|
|
capture(event) {
|
|
Events$1.capturedBy(event.data.originalEvent, this);
|
|
}
|
|
|
|
/**
|
|
* Creates children and instantiates everything.
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
setup() {
|
|
// Button
|
|
//-----------------
|
|
let button = new PIXI.Graphics();
|
|
this.button = button;
|
|
this.addChild(button);
|
|
|
|
// Content
|
|
//-----------------
|
|
let content = new PIXI.Container();
|
|
this.content = content;
|
|
this.addChild(content);
|
|
|
|
// Text
|
|
//-----------------
|
|
if (this.opts.label) {
|
|
this.text = new PIXI.Text(this.opts.label, this.opts.textStyle);
|
|
}
|
|
|
|
// Icon
|
|
//-----------------
|
|
if (this.opts.icon) {
|
|
this.iconInactive = this.loadIcon(this.opts.icon, this.opts.iconColor);
|
|
}
|
|
|
|
if (this.opts.iconActive) {
|
|
this.iconActive = this.loadIcon(this.opts.iconActive, this.opts.iconColorActive);
|
|
}
|
|
|
|
// interaction
|
|
//-----------------
|
|
this.button.on('pointerover', e => {
|
|
this.capture(e);
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.83,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
|
|
this.button.on('pointermove', e => {
|
|
this.capture(e);
|
|
});
|
|
|
|
this.button.on('pointerout', this.onEnd.bind(this));
|
|
this.button.on('pointercancel', this.onEnd.bind(this));
|
|
this.button.on('pointerupoutside', this.onEnd.bind(this));
|
|
this.button.on('pointertap', this.onEnd.bind(this));
|
|
this.button.on('scroll', this.onEnd.bind(this));
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
this.button.on('pointerdown', e => {
|
|
//this.capture(e)
|
|
this.__start.x = e.data.global.x;
|
|
this.__start.y = e.data.global.y;
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.7,
|
|
overwrite: 'none'
|
|
});
|
|
});
|
|
|
|
this.button.on('pointerup', e => {
|
|
this.capture(e);
|
|
|
|
const distance = Points.distance(e.data.global, this.__start);
|
|
|
|
if (distance < 5) {
|
|
if (this.opts.beforeAction) {
|
|
this.opts.beforeAction.call(this, e, this);
|
|
}
|
|
|
|
if (this.opts.action) {
|
|
this.opts.action.call(this, e, this);
|
|
}
|
|
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 0.83,
|
|
overwrite: 'none'
|
|
});
|
|
|
|
if (this.opts.type === 'checkbox') {
|
|
this.active = !this.active;
|
|
}
|
|
|
|
if (this.opts.afterAction) {
|
|
this.opts.afterAction.call(this, e, this);
|
|
}
|
|
}
|
|
});
|
|
|
|
// disabled
|
|
//-----------------
|
|
this.disabled = this.opts.disabled;
|
|
|
|
// active
|
|
//-----------------
|
|
this.active = this.opts.active; // calls .layout()
|
|
|
|
// tooltip
|
|
//-----------------
|
|
if (this.opts.tooltip) {
|
|
if (typeof this.opts.tooltip === 'string') {
|
|
this.tooltip = new Tooltip({
|
|
object: this,
|
|
content: this.opts.tooltip
|
|
});
|
|
} else {
|
|
this.opts.tooltip = Object.assign({}, { object: this }, this.opts.tooltip);
|
|
this.tooltip = new Tooltip(this.opts.tooltip);
|
|
}
|
|
}
|
|
|
|
// badge
|
|
//-----------------
|
|
if (this.opts.badge) {
|
|
let opts = Object.assign(
|
|
{},
|
|
{
|
|
align: 'right',
|
|
verticalAlign: 'top',
|
|
offsetLeft: 0,
|
|
offsetTop: 0
|
|
}
|
|
);
|
|
if (typeof this.opts.badge === 'string') {
|
|
opts = Object.assign(opts, { content: this.opts.badge });
|
|
} else {
|
|
opts = Object.assign(opts, this.opts.badge);
|
|
}
|
|
|
|
const badge = new Badge(opts);
|
|
this.addChild(badge);
|
|
this.badge = badge;
|
|
}
|
|
|
|
this.layout();
|
|
|
|
// set position
|
|
//-----------------
|
|
this.position.set(this.opts.x, this.opts.y);
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Should be called to refresh the layout of the button. Can be used after resizing.
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layout() {
|
|
// Clear content
|
|
//-----------------
|
|
this.removeChild(this.content);
|
|
this.content = new PIXI.Container();
|
|
this.addChild(this.content);
|
|
|
|
// Set the icon
|
|
//-----------------
|
|
let icon = null;
|
|
|
|
if (!this.active && this.iconInactive) {
|
|
icon = this.iconInactive;
|
|
} else if (this.active && this.iconActive) {
|
|
icon = this.iconActive;
|
|
}
|
|
|
|
// Set the text
|
|
//-----------------
|
|
if (this.text) {
|
|
this.text.position.set(0, 0);
|
|
}
|
|
|
|
// Width and Height
|
|
//-----------------
|
|
let width = 0;
|
|
if (icon && this.text) {
|
|
width = icon.width + this.text.width + 3 * this.opts.padding;
|
|
} else if (icon) {
|
|
width = icon.width + 2 * this.opts.padding;
|
|
} else if (this.text) {
|
|
width = this.text.width + 2 * this.opts.padding;
|
|
}
|
|
|
|
if (width < this.opts.minWidth) {
|
|
width = this.opts.minWidth;
|
|
}
|
|
|
|
let height = 0;
|
|
if (icon) {
|
|
height = icon.height + 2 * this.opts.padding;
|
|
} else if (this.text) {
|
|
height = this.text.height + 2 * this.opts.padding;
|
|
}
|
|
|
|
if (height < this.opts.minHeight) {
|
|
height = this.opts.minHeight;
|
|
}
|
|
|
|
this._width = width;
|
|
this._height = height;
|
|
|
|
// Position icon and text
|
|
//-----------------
|
|
if (icon && this.text) {
|
|
if (this.opts.iconPosition === 'right') {
|
|
icon.x = this.text.width + this.opts.padding;
|
|
} else {
|
|
this.text.x = icon.width + this.opts.padding;
|
|
}
|
|
this.content.addChild(icon, this.text);
|
|
} else if (icon) {
|
|
this.content.addChild(icon);
|
|
} else if (this.text) {
|
|
this.content.addChild(this.text);
|
|
}
|
|
|
|
this.layoutInnerContent();
|
|
this.layoutContent();
|
|
|
|
this.icon = icon;
|
|
|
|
// badge
|
|
//--------------------
|
|
if (this.badge) {
|
|
this.removeChild(this.badge);
|
|
const width = this._width;
|
|
const height = this._height;
|
|
this.addChild(this.badge);
|
|
|
|
const badge = this.badge;
|
|
|
|
switch (badge.opts.align) {
|
|
case 'left':
|
|
badge.x = -badge.width / 2 + badge.opts.offsetLeft;
|
|
break
|
|
case 'center':
|
|
badge.x = width / 2 - badge.width / 2 + badge.opts.offsetLeft;
|
|
break
|
|
case 'right':
|
|
badge.x = width - badge.width / 2 + badge.opts.offsetLeft;
|
|
}
|
|
|
|
switch (badge.opts.verticalAlign) {
|
|
case 'top':
|
|
badge.y = -badge.height / 2 + badge.opts.offsetTop;
|
|
break
|
|
case 'middle':
|
|
badge.y = height / 2 - badge.height / 2 + badge.opts.offsetTop;
|
|
break
|
|
case 'bottom':
|
|
badge.y = height - badge.height / 2 + badge.opts.offsetTop;
|
|
}
|
|
}
|
|
|
|
// draw
|
|
//-----------------
|
|
this.draw();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Calculates the positions of the content children (icon and/or text).
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layoutInnerContent() {
|
|
for (let child of this.content.children) {
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
child.y = 0;
|
|
break
|
|
case 'middle':
|
|
child.y = this.content.height / 2 - child.height / 2;
|
|
break
|
|
case 'bottom':
|
|
child.y = this.content.height - child.height;
|
|
break
|
|
}
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Sets the horizontal and vertical position of the content.
|
|
* Uses the option keys "align" and "verticalAlign".
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
layoutContent() {
|
|
switch (this.opts.align) {
|
|
case 'left':
|
|
this.content.x = this.opts.padding;
|
|
break
|
|
case 'center':
|
|
this.content.x = (this._width - this.content.width) / 2;
|
|
break
|
|
case 'right':
|
|
this.content.x = this._width - this.opts.padding - this.content.width;
|
|
break
|
|
}
|
|
|
|
switch (this.opts.verticalAlign) {
|
|
case 'top':
|
|
this.content.y = this.opts.padding;
|
|
break
|
|
case 'middle':
|
|
this.content.y = (this._height - this.content.height) / 2;
|
|
break
|
|
case 'bottom':
|
|
this.content.y = this._height - this.opts.padding - this.content.height;
|
|
break
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Draws the canvas.
|
|
*
|
|
* @private
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
draw() {
|
|
this.button.clear();
|
|
if (this.active) {
|
|
this.button.lineStyle(this.opts.strokeActiveWidth, this.opts.strokeActive, this.opts.strokeActiveAlpha);
|
|
this.button.beginFill(this.opts.fillActive, this.opts.fillActiveAlpha);
|
|
} else {
|
|
this.button.lineStyle(this.opts.strokeWidth, this.opts.stroke, this.opts.strokeAlpha);
|
|
this.button.beginFill(this.opts.fill, this.opts.fillAlpha);
|
|
}
|
|
this.button.drawRoundedRect(0, 0, this._width, this._height, this.opts.radius);
|
|
this.button.endFill();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the active state.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get active() {
|
|
return this._active
|
|
}
|
|
set active(value) {
|
|
this._active = value;
|
|
|
|
if (this._active) {
|
|
if (this.text) {
|
|
this.text.style = this.opts.textStyleActive;
|
|
}
|
|
} else {
|
|
if (this.text) {
|
|
this.text.style = this.opts.textStyle;
|
|
}
|
|
}
|
|
|
|
this.layout();
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the disabled state. When disabled, the button cannot be clicked.
|
|
*
|
|
* @member {boolean}
|
|
*/
|
|
get disabled() {
|
|
return this._disabled
|
|
}
|
|
set disabled(value) {
|
|
this._disabled = value;
|
|
|
|
if (this._disabled) {
|
|
this.button.interactive = false;
|
|
this.button.buttonMode = false;
|
|
this.button.alpha = 0.5;
|
|
if (this.icon) {
|
|
this.icon.alpha = 0.5;
|
|
}
|
|
if (this.text) {
|
|
this.text.alpha = 0.5;
|
|
}
|
|
} else {
|
|
this.button.interactive = true;
|
|
this.button.buttonMode = true;
|
|
this.button.alpha = 1;
|
|
if (this.icon) {
|
|
this.icon.alpha = 1;
|
|
}
|
|
if (this.text) {
|
|
this.text.alpha = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows the button (sets his alpha values to 1).
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
show() {
|
|
this.opts.strokeAlpha = 1;
|
|
this.opts.strokeActiveAlpha = 1;
|
|
this.opts.fillAlpha = 1;
|
|
this.opts.fillActiveAlpha = 1;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Hides the button (sets his alpha values to 0).
|
|
*
|
|
* @return {Button} A reference to the button for chaining.
|
|
*/
|
|
hide() {
|
|
this.opts.strokeAlpha = 0.0;
|
|
this.opts.strokeActiveAlpha = 0.0;
|
|
this.opts.fillAlpha = 0.0000000001; // WORKAROUND: See https://github.com/pixijs/pixi.js/wiki/v5-Migration-Guide#graphics-interaction
|
|
this.opts.fillActiveAlpha = 0.0000000001;
|
|
|
|
this.layout();
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Loads an icon
|
|
*
|
|
* @private
|
|
* @param {string|PIXI.DisplayObject} icon - The icon to load.
|
|
* @param {number} color - The color of the icon (if not an PIXI.DisplayObject).
|
|
* @return {PIXI.DisplayObject} Return the icon as an PIXI.DisplayObject.
|
|
*/
|
|
loadIcon(icon, color) {
|
|
let displayObject = null;
|
|
|
|
if (icon instanceof PIXI.DisplayObject) {
|
|
displayObject = icon;
|
|
} else {
|
|
let size = 17;
|
|
if (this.text) {
|
|
size = this.text.height;
|
|
} else if (this.opts.minHeight) {
|
|
size = this.opts.minHeight - 2 * this.opts.padding;
|
|
}
|
|
|
|
const url = Button.iconIsUrl(icon) ? icon : `../../assets/icons/${icon}.png`;
|
|
const iconTexture = PIXI.Texture.from(url);
|
|
|
|
const sprite = new PIXI.Sprite(iconTexture);
|
|
sprite.tint = color;
|
|
sprite.width = size;
|
|
sprite.height = size;
|
|
|
|
displayObject = sprite;
|
|
}
|
|
|
|
return displayObject
|
|
}
|
|
|
|
/**
|
|
* Tests if an icon string is an url.
|
|
*
|
|
* @private
|
|
* @static
|
|
* @param {string} url - The url to test.
|
|
* @return {boolean} true if the url is an url to an image.
|
|
*/
|
|
static iconIsUrl(url) {
|
|
return /\.(png|svg|gif|jpg|jpeg|tif|tiff)$/i.test(url)
|
|
}
|
|
|
|
/**
|
|
* Gets or sets the color of the current icon (no matter how the status is). Changing the color, changes
|
|
* the tint property of the icon sprite.
|
|
*
|
|
* @member {number}
|
|
*/
|
|
get iconColor() {
|
|
return this.icon ? this.icon.tint : null
|
|
}
|
|
set iconColor(value) {
|
|
if (this.icon) {
|
|
this.icon.tint = value;
|
|
}
|
|
}
|
|
|
|
onEnd(event) {
|
|
this.capture(event);
|
|
TweenLite.to([this.button, this.content], this.theme.fast, {
|
|
alpha: 1,
|
|
overwrite: 'none'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The EventHandler class is used to take care of a event based design
|
|
* pattern. Callbacks can subscribe to an event and these unknown sources
|
|
* get notified whenever the event changes.
|
|
*
|
|
* @export
|
|
* @class EventHandler
|
|
*/
|
|
class EventHandler {
|
|
/**
|
|
* Creates an instance of EventHandler.
|
|
* @param {any} name
|
|
* @param {any} [{
|
|
* listeners = [] - With the listnerers parameter the user can specify a function, array of functions or null (no function - useful when used in constructor with optional parameter).
|
|
* }={}]
|
|
* @memberof EventHandler
|
|
*/
|
|
constructor(name, { listeners = [] } = {}) {
|
|
this.name = name;
|
|
this.listeners = [];
|
|
this.onces = [];
|
|
|
|
/**
|
|
* One may initialize the eventListener using a parameter
|
|
* that is either passed or null.
|
|
*/
|
|
|
|
if (listeners == null) ; else if (Array.isArray(listeners)) this.listeners = listeners;
|
|
else if (typeof listeners == 'function') {
|
|
this.listeners = [];
|
|
this.add(listeners);
|
|
} else {
|
|
console.warn(
|
|
"The provided 'listeners' is neither an Array of functions, nor a function. No eventcallback was added!",
|
|
listeners,
|
|
this
|
|
);
|
|
}
|
|
}
|
|
|
|
addMultiple(...callbacks) {
|
|
for (let callback of callbacks) {
|
|
this.listeners.push(callback);
|
|
}
|
|
}
|
|
|
|
add(callback) {
|
|
this.listeners.push(callback);
|
|
return callback
|
|
}
|
|
|
|
once(callback) {
|
|
this.onces.push(callback);
|
|
}
|
|
|
|
remove(func) {
|
|
for (const [idx, listener] of this.listeners.entries()) {
|
|
if (listener === func) {
|
|
this.listeners.splice(idx, 1);
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
empty() {
|
|
this.listeners = [];
|
|
}
|
|
|
|
call(context, ...args) {
|
|
if (context == null) {
|
|
this.listeners.forEach(listener => listener(...args));
|
|
this.onces.forEach(listener => listener(...args));
|
|
} else {
|
|
this.listeners.forEach(listener => listener.call(context, ...args));
|
|
this.onces.forEach(listener => listener.call(context, ...args));
|
|
}
|
|
|
|
this.onces = [];
|
|
}
|
|
|
|
get length() {
|
|
return this.listeners.length + this.onces.length
|
|
}
|
|
}
|
|
|
|
/* ES Lint */
|
|
|
|
/**
|
|
* The AdvancedScatterContainer extends the ScatterContainer, but
|
|
* uses the findTargetNew instead of the findTarget method, which takes interactive elements
|
|
* into account, when calculating the target.
|
|
*/
|
|
class AdvancedScatterContainer extends ScatterContainer {
|
|
applyToChildScattersRecursively(parent, root, applyFunc) {
|
|
for (let child of parent.children) {
|
|
if (child.scatter) {
|
|
applyFunc(child, root);
|
|
this.applyToChildScattersRecursively(child, root, applyFunc);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The update method was adjusted to draw bounds and polygpns
|
|
*/
|
|
update(dt) {
|
|
this.clear();
|
|
this.lineStyle(1, 0x0000ff);
|
|
if (this.showBounds) {
|
|
this.applyToChildScattersRecursively(this, this, (child, root) => {
|
|
let position = child.worldTransform.apply(new PIXI.Point(0, 0));
|
|
|
|
this.drawShape(child.scatter.bounds);
|
|
|
|
let center = {
|
|
x: child.scatter.width / 2 / child.scatter.scale,
|
|
y: child.scatter.height / 2 / child.scatter.scale
|
|
};
|
|
center = child.worldTransform.apply(center);
|
|
|
|
this.drawCircle(center.x, center.y, 4);
|
|
this.drawCircle(position.x, position.y, 4);
|
|
});
|
|
|
|
this.lineStyle(2, 0x0000ff);
|
|
this.drawShape(this.bounds);
|
|
}
|
|
if (this.showPolygon) {
|
|
this.lineStyle(2, 0xff0000);
|
|
for (let child of this.children) {
|
|
if (child.scatter) {
|
|
let polygon = child.scatter.polygon;
|
|
let shape = new PIXI.Polygon(polygon.flatAbsolutePoints());
|
|
if (
|
|
shape.points[0] !== shape.points[shape.points.length - 2] ||
|
|
shape.points[1] !== shape.points[shape.points.length - 1]
|
|
) {
|
|
shape.points.push(shape.points[0], shape.points[1]);
|
|
}
|
|
this.drawShape(shape);
|
|
}
|
|
}
|
|
}
|
|
if (this.showTouches) {
|
|
let current = this.delegate.interaction.current;
|
|
for (let [key, point] of current.entries()) {
|
|
let local = this.mapPositionToPoint(point);
|
|
this.drawCircle(local.x, local.y, 12);
|
|
}
|
|
}
|
|
}
|
|
|
|
get width() {
|
|
return this.bounds.width
|
|
}
|
|
|
|
get height() {
|
|
return this.bounds.width
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
// UO: still problematic. Does not find non interactive elements
|
|
// which are needed for some stylus applications
|
|
if (event.claimedByScatter) {
|
|
return null
|
|
}
|
|
|
|
this.hitScatter = null;
|
|
let interactionManager = this.renderer.plugins.interaction;
|
|
|
|
let displayObject = interactionManager.hitTest(local, this);
|
|
if (displayObject != null) {
|
|
if (displayObject.dontBlockScatter && displayObject.parent != null) {
|
|
displayObject = interactionManager.hitTest(local, displayObject.parent);
|
|
}
|
|
|
|
if (displayObject.scatter != null) this.hitScatter = displayObject.scatter;
|
|
if (this.claimEvents) event.claimedByScatter = this.hitScatter;
|
|
}
|
|
|
|
return this.hitScatter
|
|
}
|
|
}
|
|
|
|
class RigidScatterContainer extends AdvancedScatterContainer {
|
|
constructor(width, height, renderer, opts) {
|
|
super(renderer, opts);
|
|
this._width = width;
|
|
this._height = height;
|
|
}
|
|
|
|
resize(width, height) {
|
|
this._width = width;
|
|
this._height = height;
|
|
}
|
|
|
|
get width() {
|
|
return this._width
|
|
}
|
|
|
|
get height() {
|
|
return this._height
|
|
}
|
|
|
|
_calculateBounds() {
|
|
this._bounds.clear();
|
|
let bounds = new PIXI.Bounds();
|
|
bounds.minX = this.parent.position.x;
|
|
bounds.minY = this.parent.position.y;
|
|
bounds.maxX = this.parent.position.x + this.width;
|
|
bounds.maxY = this.parent.position.y + this.height;
|
|
this._bounds.addBounds(bounds);
|
|
}
|
|
}
|
|
|
|
class RigidContainer extends PIXI.Graphics {
|
|
constructor(width, height, { onResize = null } = {}) {
|
|
super();
|
|
if (!width || !height) {
|
|
console.log(
|
|
`Width (${width}) or height (${height}) not set! If this is intended use another PIXI.DisplayObject instead.`,
|
|
this
|
|
);
|
|
}
|
|
this._width = width;
|
|
this._height = height;
|
|
this.beginFill(0xffffff, 0.3);
|
|
this.drawRect(0, 0, this.width, this.height);
|
|
|
|
this.onResize = new EventHandler('resize', {
|
|
listeners: onResize ? [onResize] : []
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resizes the scattercontainer to the provided dimensions.
|
|
*
|
|
* @param {number} width - Target width of the resize.
|
|
* @param {number} height - Target height of the resize.
|
|
* @memberof RigidContainer
|
|
*/
|
|
resize(width, height) {
|
|
this._width = width;
|
|
this._height = height;
|
|
this._updateHitArea();
|
|
this.onResize.call(this, 'resize', { x: width, y: height });
|
|
}
|
|
|
|
_updateHitArea() {
|
|
// this.hitArea = new PIXI.Rectangle(0,0, this.width, this.height)
|
|
}
|
|
|
|
get width() {
|
|
return this._width
|
|
}
|
|
get height() {
|
|
return this._height
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialization of the DisplayObjectScatter with special behavior needed for maps.
|
|
*/
|
|
class AdvancedScatter extends DisplayObjectScatter {
|
|
/**
|
|
* Animates the throw and ensures that the map is always visible. This
|
|
* is different from the standard behavior.
|
|
*
|
|
* @param {*} time
|
|
* @memberof AdvancedScatter
|
|
*/
|
|
animateThrow(time) {
|
|
// In rare cases animateThrow is called when the displayObject is already removed
|
|
if (this.displayObject.parent == null) {
|
|
return
|
|
}
|
|
|
|
//Moved this outside, as the time continously increases,
|
|
//when the object is not thrown.
|
|
|
|
if (this.velocity != null) {
|
|
let dt = this._throwDeltaTime();
|
|
// This seems to just correct the velocity when its calculated wrong.
|
|
// In what case can it get bigger? Velocity tends to always shrink, when no force is added.
|
|
let d = this._getThrowDelta(dt);
|
|
this._move(d);
|
|
this.onDragUpdate(d);
|
|
if (dt == 0 || this.needsAnimation()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
} else {
|
|
if (this.isOutside()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
}
|
|
}
|
|
}
|
|
this.onDragComplete();
|
|
}
|
|
|
|
get parent() {
|
|
// Returns the parent is a scatter get the parent scatter.
|
|
// If it's a scatter container, get the displayObject.
|
|
// Other cases are not allowed.
|
|
return this.displayObject.parent != null && this.displayObject.parent.scatter != null
|
|
? this.displayObject.parent.scatter
|
|
: this.displayObject.parent
|
|
}
|
|
|
|
_getThrowDelta(dt) {
|
|
this.velocity = this.nextVelocity(this.velocity);
|
|
if (this.velocity != null) {
|
|
return Points.multiplyScalar(this.velocity, dt)
|
|
}
|
|
return { x: 0, y: 0 }
|
|
}
|
|
|
|
freeze({ translatable = false, scalable = false, rotatable = false, movableX = false, movableY = false } = {}) {
|
|
this.translatable = translatable;
|
|
this.scalable = scalable;
|
|
this.rotatable = rotatable;
|
|
this.movableX = movableX;
|
|
this.movableY = movableY;
|
|
}
|
|
|
|
unfreeze({ translatable = true, scalable = true, rotatable = true, movableX = true, movableY = true } = {}) {
|
|
this.freeze({
|
|
translatable,
|
|
scalable,
|
|
rotatable: false,
|
|
movableX,
|
|
movableY
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TODO
|
|
* A scatter which width does not increase when child elements are added.
|
|
*/
|
|
class CompactScatter extends AdvancedScatter {}
|
|
|
|
/**
|
|
* A problem with PIXI Objects is, that the bounding box changes
|
|
* with child elements. Therefore the sized scatter has a rigid size, that is not
|
|
* changed by elements, but only with their width and height property.
|
|
*/
|
|
class CoverScatter extends AdvancedScatter {
|
|
constructor(a, b, opts) {
|
|
super(a, b, opts);
|
|
|
|
opts = Object.assign(
|
|
{
|
|
debug: false,
|
|
cover: true,
|
|
boundaries: {
|
|
min: { x: 0, y: 0 },
|
|
max: { x: 1, y: 1 }
|
|
}
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.debug = opts.debug;
|
|
this.cover = opts.cover;
|
|
this._boundaries = opts.boundaries;
|
|
|
|
if (this.debug) {
|
|
this.debugGraphics = new PIXI.Graphics();
|
|
this.displayObject.addChild(this.debugGraphics);
|
|
this.debugGraphics.lineStyle(0.2, 0x00ff00);
|
|
this.debugGraphics.drawRect(
|
|
this.width * this.boundaries.min.x,
|
|
this.height * this.boundaries.min.y,
|
|
this.width * (this.boundaries.max.x - this.boundaries.min.x),
|
|
this.height * (this.boundaries.max.y - this.boundaries.min.y)
|
|
);
|
|
|
|
this.debugGraphics.endFill();
|
|
}
|
|
|
|
if (this.cover) {
|
|
// The reference to the element handler needs to be stored,
|
|
// that we can remove it later on.
|
|
this._applyInitialCover = this._applyInitialCover.bind(this);
|
|
this.displayObject.on('added', this._applyInitialCover);
|
|
this._applyInitialCover();
|
|
}
|
|
}
|
|
|
|
_applyInitialCover() {
|
|
if (this.debug) console.log('ApplyInitialCover: ', parent);
|
|
|
|
if (this.displayObject.parent)
|
|
this.forceCover(this.displayObject.parent.width, this.displayObject.parent.height);
|
|
}
|
|
|
|
get boundaries() {
|
|
if (this._boundaries) return this._boundaries
|
|
else
|
|
return {
|
|
min: { x: 0, y: 0 },
|
|
max: { x: 1, y: 1 }
|
|
}
|
|
}
|
|
|
|
transform(translate, zoom, rotate, anchor) {
|
|
if (this.cover) {
|
|
if (!this.parent) {
|
|
return new PIXI.Rectangle(0, 0, window.innerWidth, window.innerHeight)
|
|
}
|
|
|
|
/**
|
|
* If the scaling violates the minCoverScale, the zoom is adjusted to perfectly match
|
|
* the minCoverScale.
|
|
*/
|
|
let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height);
|
|
|
|
let { zoom: fixedZoom, scale } = this.calculateScale(zoom);
|
|
if (scale < minCoverScale) {
|
|
zoom = minCoverScale / this.scale;
|
|
}
|
|
}
|
|
|
|
super.transform(translate, zoom, rotate, anchor);
|
|
if (this.cover) {
|
|
let postTranslate = this.outsideBoundaries();
|
|
super.transform(postTranslate, 1, 0, { x: 0, y: 0 });
|
|
}
|
|
}
|
|
|
|
outsideBoundaries(movement = { x: 0, y: 0 }, { boundaries = this.boundaries } = {}) {
|
|
if (this.parent == null) {
|
|
return { x: 0, y: 0 }
|
|
}
|
|
let left = -(this.displayObject.position.x + movement.x + this.width * boundaries.min.x);
|
|
let right = -left + this.width * (boundaries.max.x - boundaries.min.x) - this.parent.width;
|
|
|
|
let top = -(this.displayObject.position.y + movement.y + this.height * boundaries.min.y);
|
|
let bot = -top + this.height * (boundaries.max.y - boundaries.min.y) - this.parent.height;
|
|
|
|
// Helper function to limit an Axis inside a container, or 'glue' it
|
|
// to the lower side, if the size is smaller than the size of the
|
|
// container.
|
|
function limitAxis(low, high, contentSize, containerSize) {
|
|
let val = 0;
|
|
if (low < 0) {
|
|
val = low;
|
|
} else if (high < 0) {
|
|
if (contentSize > containerSize) {
|
|
// ... and the element is bigger as the container
|
|
// - reset it to the containersize.
|
|
val = -high;
|
|
} else {
|
|
// ... and the size is not high enough, then
|
|
// 'glue' it to the low axis.
|
|
val = low;
|
|
}
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
// Use the helper function to correct the movement to cover x and y.
|
|
let correctionX = limitAxis(left, right, this.width * (boundaries.max.x - boundaries.min.x), this.parent.width);
|
|
let correctionY = limitAxis(top, bot, this.height * (boundaries.max.y - boundaries.min.y), this.parent.height);
|
|
let fixedMovement = {
|
|
x: correctionX == 0 ? movement.x : correctionX + movement.x,
|
|
y: correctionY == 0 ? movement.y : correctionY + movement.y
|
|
};
|
|
|
|
return fixedMovement
|
|
}
|
|
|
|
calculateMinCoverScale(width, height) {
|
|
let scale = 0;
|
|
|
|
if (!(this.width == 0 && this.height == 0)) {
|
|
let actualWidth = this.width / this.scale;
|
|
let actualHeight = this.height / this.scale;
|
|
|
|
let boundaryWidth = (this.boundaries.max.x - this.boundaries.min.x) * actualWidth;
|
|
let boundaryHeight = (this.boundaries.max.y - this.boundaries.min.y) * actualHeight;
|
|
|
|
let coverWidth = width / boundaryWidth;
|
|
let coverHeight = height / boundaryHeight;
|
|
|
|
scale = Math.max(coverWidth, coverHeight);
|
|
}
|
|
|
|
return scale
|
|
}
|
|
|
|
_getThrowDelta(dt) {
|
|
let delta = super._getThrowDelta(dt);
|
|
if (this.cover) {
|
|
delta = this.outsideBoundaries(delta, {
|
|
boundaries: this.boundaries
|
|
});
|
|
}
|
|
return delta
|
|
}
|
|
|
|
containerChanged(width, height) {
|
|
if (this.cover) this.forceCover(width, height);
|
|
}
|
|
|
|
forceCover() {
|
|
this.requestScale(this.scale);
|
|
let translate = this.outsideBoundaries();
|
|
this.transform(translate, 1, 0, { x: 0, y: 0 });
|
|
}
|
|
|
|
requestScale(scale) {
|
|
if (this.scalable && this.parent != null) {
|
|
if (this.cover) {
|
|
let minCoverScale = this.calculateMinCoverScale(this.parent.width, this.parent.height);
|
|
if (scale < minCoverScale) {
|
|
scale = minCoverScale;
|
|
}
|
|
}
|
|
this.scale = scale;
|
|
}
|
|
}
|
|
|
|
requestFocus(point) {
|
|
let allowedMovement = this.outsideBoundaries(Points.subtract(point, this.position));
|
|
this.transform(allowedMovement, 1, 0, { x: 0, y: 0 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The MapObjectScatter somewhat inverts the behaviour of
|
|
* regular throwables. Regular throwables should bounce of the wall, when they
|
|
* overlap to a certain amount. Maps on the otherhand are intended to overlap with the
|
|
* scatter container heavily to hide irrelevant parts of the map without revealing
|
|
* any background.
|
|
*
|
|
* @class
|
|
* @extends DisplayObjectScatter
|
|
*/
|
|
|
|
class MapObjectScatter extends CoverScatter {
|
|
/**
|
|
* x=null, y=null,
|
|
minScale=0.1,
|
|
maxScale=1.0,
|
|
startScale=1.0,
|
|
autoBringToFront=true,
|
|
translatable=true, scalable=true, rotatable=true, resizable=false,
|
|
movableX=true,
|
|
movableY=true,
|
|
throwVisibility=44,
|
|
throwDamping = 0.95,
|
|
autoThrow=true,
|
|
rotationDegrees=null,
|
|
rotation=null,
|
|
onTransform = null }
|
|
*/
|
|
constructor(displayObject, renderer, opts = {}) {
|
|
opts = Object.assign(
|
|
{
|
|
debug: false,
|
|
cover: false,
|
|
startScale: 1,
|
|
boundaries: {
|
|
min: { x: 0, y: 0 },
|
|
max: { x: 1, y: 1 }
|
|
},
|
|
autoBringToFront: false
|
|
},
|
|
opts
|
|
);
|
|
super(displayObject, renderer, opts);
|
|
|
|
// if (!renderer) {
|
|
// console.error('Renderer was not set!')
|
|
// return
|
|
// }
|
|
|
|
this.cover = opts.cover;
|
|
}
|
|
|
|
moveTo() {
|
|
super.moveTo(...arguments);
|
|
}
|
|
|
|
lock() {
|
|
this.rotatable = false;
|
|
this.moveable = false;
|
|
this.scaleable = false;
|
|
}
|
|
|
|
unlock() {
|
|
this.rotatable = true;
|
|
this.moveable = true;
|
|
this.scaleable = true;
|
|
}
|
|
|
|
getWorldScatter() {
|
|
return this.parent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* MapData contains the informations about how
|
|
* a Map has to be interpreted. What are the bounds of the
|
|
* map and how to translate coordinates into
|
|
* image positions.
|
|
*
|
|
* @class
|
|
* @see {@link mapdata.html}
|
|
*/
|
|
class MapData {
|
|
/**
|
|
* Creates instance of MapData
|
|
*
|
|
* @constructor
|
|
* @param {Projection}[projection] - Specifies the projection of the map (e.g. Mercator Projection).
|
|
* @param {object}[opts] - Addiditonal options.
|
|
* @param {[[minLat, minLng],[maxLat, maxLng]]}[opts.bounds] - Describes the minimum and maximum coordinates on the map
|
|
* @param {Point}[opts.translate] - Defines a translation, when clipping is not an option (e.g. when the whole world is shown, but translated.)
|
|
*/
|
|
constructor(projection, opts = {}) {
|
|
this.opts = Object.assign(
|
|
{
|
|
translate: { x: 0, y: 0 }
|
|
},
|
|
opts
|
|
);
|
|
|
|
this.projection = projection;
|
|
|
|
if (this.opts.clip) {
|
|
let _cmin = this.projection.forward(this.opts.clip.min);
|
|
let _cmax = this.projection.forward(this.opts.clip.max);
|
|
|
|
// Swaps the y values, Mercator has it's origin bottom right,
|
|
// browser coordinates start top right.
|
|
let cmin = { x: _cmin.x, y: _cmax.y };
|
|
let cmax = { x: _cmax.x, y: _cmin.y };
|
|
|
|
this.clipExt = {
|
|
coords: {
|
|
min: this.opts.clip.min,
|
|
max: this.opts.clip.max
|
|
},
|
|
point: {
|
|
min: cmin,
|
|
max: cmax
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transforms a pixel point on the map to a geographical coordinate.
|
|
*
|
|
* @param {{x,y} | PIXI.Point} point - A pixel position on the map.
|
|
* @returns {{x,y} | PIXI.Point} - A geographical coordinate.
|
|
* @memberof MapData
|
|
*/
|
|
toCoordinates(point) {
|
|
if (this.opts.clip) {
|
|
let min = this.clipExt.point.min;
|
|
let max = this.clipExt.point.max;
|
|
|
|
let width = max.x - min.x;
|
|
let height = max.y - min.y;
|
|
|
|
point.x *= width;
|
|
point.y *= height;
|
|
|
|
point.x += min.x;
|
|
point.y += min.y;
|
|
}
|
|
|
|
let coordinates = this.projection.backward(point);
|
|
|
|
if (this.opts.translate) {
|
|
coordinates.x -= this.opts.translate.x;
|
|
coordinates.y -= this.opts.translate.y;
|
|
}
|
|
|
|
return coordinates
|
|
}
|
|
|
|
/**
|
|
* Transform a geographical coordinate to a pixel point on the map.
|
|
*
|
|
* @param {{x,y} | PIXI.Point} coordinates - A point in the form of {x:lat,y:lng}.
|
|
* @returns {{x,y} | PIXI.Point} point - A pixel position on the map.
|
|
* @memberof MapData
|
|
*/
|
|
toPixel(coordinates) {
|
|
let coords = { x: coordinates.x, y: coordinates.y };
|
|
if (this.opts.translate) {
|
|
coords.x += this.opts.translate.x;
|
|
coords.y += this.opts.translate.y;
|
|
}
|
|
|
|
let point = this.projection.forward(coords);
|
|
|
|
if (this.opts.clip) {
|
|
let min = this.clipExt.point.min;
|
|
let max = this.clipExt.point.max;
|
|
|
|
let clippedPoint = {
|
|
x: point.x - min.x,
|
|
y: point.y - min.y
|
|
};
|
|
|
|
let width = max.x - min.x;
|
|
let height = max.y - min.y;
|
|
|
|
point.x = clippedPoint.x / width;
|
|
point.y = clippedPoint.y / height;
|
|
}
|
|
|
|
return point
|
|
}
|
|
|
|
get clip() {
|
|
let unclipped = {
|
|
min: { x: 0, y: 0 },
|
|
max: { x: 1, y: 1 }
|
|
};
|
|
|
|
return this.opts.clip ? this.opts.clip : unclipped
|
|
}
|
|
|
|
/**
|
|
* Bounds to pixel transforms some bounds in form of {min:{x:minLat, y:minLng},max:{x:maxLat, y:maxLng}}
|
|
* to pixel coordinates.
|
|
*
|
|
*/
|
|
getBoundaries() {
|
|
// let min = this.toPixel(bounds.min)
|
|
// let max = this.toPixel(bounds.max)
|
|
|
|
// Y values needs to be swapped, as PIXI has it's origin
|
|
// in the top-left corner and a regular map in the bottom-left corner.
|
|
let boundaries = {
|
|
min: { x: 0, y: 0 },
|
|
max: { x: 1, y: 1 }
|
|
};
|
|
|
|
return boundaries
|
|
}
|
|
|
|
get maxViewport() {
|
|
return this.opts.clip ? this.opts.clip : this.projection.maxViewport
|
|
}
|
|
}
|
|
|
|
class DeepZoomMapData extends MapData {
|
|
constructor(projection, tilesConfig, opts = {}) {
|
|
if (tilesConfig.clip) {
|
|
opts.clip = {
|
|
min: {
|
|
x: tilesConfig.clip.bounds.min[0],
|
|
y: tilesConfig.clip.bounds.min[1]
|
|
},
|
|
max: {
|
|
x: tilesConfig.clip.bounds.max[0],
|
|
y: tilesConfig.clip.bounds.max[1]
|
|
}
|
|
};
|
|
}
|
|
|
|
super(projection, opts);
|
|
this.app = opts.app;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A projection determines how a geographical card has to
|
|
* be interpreted to map coordinate to pixels.
|
|
*
|
|
* Most used transformation is the mercator projection,
|
|
* which projects a sphere on a cylinder.
|
|
*
|
|
* @abstract
|
|
*/
|
|
|
|
class Projection {
|
|
/**
|
|
* Transforms a coordinate to a normalized position on the map.
|
|
*
|
|
* @param {*} coords
|
|
* @memberof Projection
|
|
*/
|
|
forward(coords) {
|
|
console.error('You must override the forward function in ' + this.name + '.');
|
|
}
|
|
|
|
/**
|
|
* Transforms a normalized point on the map to a coordinate.
|
|
*
|
|
* @param {*} point
|
|
* @memberof Projection
|
|
*/
|
|
backward(point) {
|
|
console.error('You must override the backward function in ' + this.name + '.');
|
|
}
|
|
|
|
toString() {
|
|
return 'Projection (abstract)'
|
|
}
|
|
|
|
get name() {
|
|
return this.toString()
|
|
}
|
|
|
|
get maxViewport() {
|
|
return { min: new PIXI.Point(0, 0), max: new PIXI.Point(1, 1) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This is a projection file, that grants access to the
|
|
* MERCATOR projection.
|
|
*
|
|
* Regulary only few Projections will be used in one
|
|
* project, therefore only required one's should be
|
|
* loaded.
|
|
*/
|
|
|
|
class Mercator extends Projection {
|
|
forward(coords) {
|
|
let lat = coords.x;
|
|
let lng = coords.y;
|
|
|
|
const PI_180 = Math.PI / 180.0;
|
|
const PI_4 = Math.PI * 4;
|
|
|
|
const sinLatitude = Math.sin(lat * PI_180);
|
|
let y = 0.5 - Math.log((1 + sinLatitude) / (1 - sinLatitude)) / PI_4;
|
|
let x = (lng + 180) / 360;
|
|
|
|
y = y < 0 ? 0 : y > 1 ? 1 : y;
|
|
|
|
return new PIXI.Point(x, y)
|
|
}
|
|
|
|
backward(point) {
|
|
let lng = point.x * 360 - 180;
|
|
let lat = (Math.asin(-2 / (Math.exp(4 * Math.PI * (0.5 - point.y)) + 1) + 1) * 180) / Math.PI;
|
|
|
|
return new PIXI.Point(lat, lng)
|
|
}
|
|
|
|
toString() {
|
|
return 'Mercator Projection'
|
|
}
|
|
|
|
get maxViewport() {
|
|
return { min: new PIXI.Point(-85, -180), max: new PIXI.Point(85, 180) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The GeoMap class displays a map, that it gets from MapData object.
|
|
* It handles the current location on the map, the zoom factor, interactions and
|
|
* the viewport, the area of the map, the user can see and navigate to.
|
|
*
|
|
* You should not instantiate GeoMap on it's own. Use the implemented child classes
|
|
* or derive a new one from it.
|
|
*
|
|
* Note: The name Map is already in use. Therefore GeoMap was used instead.
|
|
*
|
|
|
|
* @abstract
|
|
* @class
|
|
* @see {@link maps.html}
|
|
*/
|
|
|
|
class GeoMap {
|
|
/**
|
|
* Creates instance of GeoMap
|
|
*
|
|
* @constructor
|
|
* @param {MapData}[mapdata={}] - The mapdata describes how the map has to be interpreted by the Map class. E.g. what are the boundaries of the map?
|
|
* @param {object}[opts={}] - With the opts, the created MapObjectScatter can be adjusted.
|
|
* @param {boolean}[opts.cover=false] - Enables covering behaviour of a map object. Normally maps should cover the whole app.
|
|
*/
|
|
constructor(
|
|
mapdata = {},
|
|
{
|
|
debug = true,
|
|
cover = true,
|
|
// Scatter Options
|
|
alpha = 1,
|
|
startScale = 1,
|
|
minScale = 0,
|
|
maxScale = Infinity,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = false, // Many functionalities are not supported when rotating the map. Mainly the cover mechanism.
|
|
viewport = mapdata.maxViewport,
|
|
// Events
|
|
onLoad = null,
|
|
onTransform = null
|
|
} = {}
|
|
) {
|
|
this._id = GeoMap.counter++;
|
|
this.onLoad = new EventHandler('loaded', { listeners: onLoad });
|
|
this.onTransform = new EventHandler('transform', { listeners: onTransform });
|
|
|
|
this._alpha = alpha;
|
|
this.cover = cover;
|
|
this.debug = debug;
|
|
|
|
//TODO discuss if this is required here.
|
|
// Those are just scatter options and the information
|
|
// is redundant in the map class and the scatter.
|
|
this.startScale = startScale;
|
|
this.minScale = minScale;
|
|
this.maxScale = maxScale;
|
|
this.rotatable = rotatable;
|
|
this.translatable = translatable;
|
|
this.scalable = scalable;
|
|
this.viewport = viewport;
|
|
|
|
this.mapdata = mapdata;
|
|
this.overlays = {};
|
|
|
|
/**
|
|
* Adjust the viewport depending on the mapdata clipping.
|
|
*/
|
|
if (this.mapdata.clip) {
|
|
const vp = this.viewport;
|
|
const cp = this.mapdata.clip;
|
|
let bounds = {
|
|
min: {
|
|
x: vp.min.x > cp.min.x ? vp.min.x : cp.min.x,
|
|
y: vp.min.y > cp.min.y ? vp.min.y : cp.min.y
|
|
},
|
|
max: {
|
|
x: vp.max.x < cp.max.x ? vp.max.x : cp.max.x,
|
|
y: vp.max.y < cp.max.y ? vp.max.y : cp.max.y
|
|
}
|
|
};
|
|
|
|
this.viewport = bounds;
|
|
}
|
|
}
|
|
|
|
flushHandlers() {
|
|
// this.onLoaded
|
|
this.onTransform.empty();
|
|
}
|
|
|
|
lock({ rotatable = false, translatable = false, movableX = false, movableY = false, scalable = false } = {}) {
|
|
if (this.image && this.image.scatter) {
|
|
this.image.scatter.translatable = rotatable;
|
|
this.image.scatter.movableX = translatable;
|
|
this.image.scatter.movableY = movableX;
|
|
this.image.scatter.rotatable = movableY;
|
|
this.image.scatter.scalable = scalable;
|
|
}
|
|
|
|
// Issue #001: This causes the map to not be displayed at the correct position on
|
|
// map change.
|
|
// // Rotation does not yet work with the cover mechanism.
|
|
// //this.rotatable = false
|
|
// this.translatable = false
|
|
// this.scalable = false
|
|
}
|
|
|
|
unlock({ rotatable = true, translatable = true, movableX = true, movableY = true, scalable = true } = {}) {
|
|
if (this.image && this.image.scatter) {
|
|
this.image.scatter.translatable = translatable;
|
|
this.image.scatter.movableX = movableX;
|
|
this.image.scatter.movableY = movableY;
|
|
this.image.scatter.rotatable = rotatable;
|
|
this.image.scatter.scalable = scalable;
|
|
}
|
|
// Issue #001
|
|
// // Rotation does not yet work with the cover mechanism.
|
|
// //this.rotatable = true
|
|
// this.translatable = true
|
|
// this.scalable = true
|
|
}
|
|
|
|
unload() {
|
|
if (this.image) {
|
|
if (this.image.parent) {
|
|
this.image.parent.removeChild(this.image);
|
|
}
|
|
|
|
if (this.scatter) {
|
|
this.scatter.killAnimation();
|
|
this.image.scatter = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
remove() {
|
|
if (this.image) this.image.mask = null;
|
|
|
|
this.removeFrame();
|
|
this.onTransform.empty();
|
|
this.onLoad.empty();
|
|
|
|
this.unload();
|
|
}
|
|
|
|
/**
|
|
* Is called when the scatter object is transformed.
|
|
*
|
|
* @private
|
|
* @param {ScatterEvent}[e] - Contains informations on how the element was transformed.
|
|
*/
|
|
transformed(e) {
|
|
this.onTransform.call(this, e);
|
|
}
|
|
|
|
/**
|
|
* Wrapps the display object around a scatter object.
|
|
*
|
|
* @private
|
|
* @param {DisplayObject} displayObject - Defines the display object that will be wrapped inside the scatter object.
|
|
* @param {PIXI.Container} container - Defines the frame in which the map will be displayed.
|
|
*/
|
|
load(image, renderer, frame = null, scatter = null) {
|
|
if (this.debug) console.log('Load image: ', image, frame);
|
|
|
|
this.image = image;
|
|
if (frame) this.setFrame(frame);
|
|
|
|
let boundaries = this.mapdata.getBoundaries();
|
|
|
|
let scatterOpts = Object.assign({
|
|
cover: this.cover,
|
|
scaleable: this.scaleable,
|
|
translatable: this.translatable,
|
|
rotatable: this.rotatable,
|
|
debug: this.debug,
|
|
startScale: this.startScale,
|
|
minScale: this.minScale,
|
|
maxScale: this.maxScale,
|
|
boundaries,
|
|
onTransform: this.transformed.bind(this)
|
|
});
|
|
|
|
this.scatter = new MapObjectScatter(image, renderer, scatterOpts);
|
|
this.image.scatter = scatter == null ? this.scatter : scatter;
|
|
|
|
this.onLoad.call(this);
|
|
}
|
|
|
|
/**
|
|
* Creates an identical copy of the current map.
|
|
*/
|
|
clone() {
|
|
console.error('Method clone() is abstract. Overload it in subclass', this);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Moves the 'camera' to another location on the map.
|
|
*
|
|
* @abstract
|
|
* @param {object} coordinates - Target coordinates in form {x: latitude, y: longitude}
|
|
* @param {number} scale - Target scale.
|
|
*/
|
|
moveTo(coordinates, zoom = null, { animate = false } = {}) {
|
|
console.error(this, 'The method moveTo(coordinates, zoom, opts) has not been defined in child class.');
|
|
}
|
|
|
|
zoomTo(zoomFactor) {
|
|
console.error('Method zoomTo() is abstract. Overload it in subclass', this, zoomFactor);
|
|
}
|
|
|
|
/**
|
|
* Reloads the textures of the image, when it was unloaded and is required again.
|
|
*
|
|
* @memberof GeoMap
|
|
*/
|
|
reloadImage() {
|
|
console.error(`Call of abstract method reloadImage(). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* Unloads the textures of the image, when not in use.
|
|
*
|
|
* @memberof GeoMap
|
|
*/
|
|
unloadImage() {
|
|
console.error(`Call of abstract method unloadImage(). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* Transforms a point at pixel position {x,y}
|
|
* to a coordinate with latitude and longitude.
|
|
*
|
|
*
|
|
* @param {object} point - Point in form of {x: x_val, y: y_val}.
|
|
* @returns {object} - Coordinates on the map in form of {x: latitude, y: longitude}.
|
|
*/
|
|
coordinatesFromPoint(point) {
|
|
let coords = this.mapdata.toCoordinates(this.toRelativePosition(point));
|
|
return coords
|
|
}
|
|
|
|
/**
|
|
* Transform coordinates in the map into pixel positions on the deep zoom image.
|
|
*
|
|
* @param {object} coords - Coordinates of a map position in form {x: latitude, y: longitude}.
|
|
* @return - Returns a image position in form of {x: x_val, y: y_val}.
|
|
*/
|
|
coordinatesToPoint(coordinates) {
|
|
return this.toAbsolutePixelCoordinates(this.mapdata.toPixel(coordinates))
|
|
}
|
|
|
|
toRelativePosition(point) {
|
|
let _point = new PIXI.Point();
|
|
|
|
_point.x = point.x / this.width;
|
|
_point.y = point.y / this.height;
|
|
|
|
return _point
|
|
}
|
|
|
|
toAbsolutePixelCoordinates(point) {
|
|
let _point = new PIXI.Point();
|
|
_point.x = point.x * this.width;
|
|
_point.y = point.y * this.height;
|
|
|
|
return _point
|
|
}
|
|
|
|
get width() {
|
|
return this.image.scatter.width / this.image.scatter.scale
|
|
}
|
|
|
|
get height() {
|
|
return this.image.scatter.height / this.image.scatter.scale
|
|
}
|
|
|
|
get distance() {
|
|
console.error('Overload get distance in subclass.');
|
|
}
|
|
|
|
get alpha() {
|
|
return this._alpha
|
|
}
|
|
|
|
/**
|
|
* Returns a screen point for a coordinate.
|
|
*/
|
|
positionInWindow(coordinates) {
|
|
let pos = this.coordinatesToPoint(coordinates);
|
|
|
|
let framePos = {
|
|
x: this.image.position.x + pos.x * this.image.scale.x,
|
|
y: this.image.position.y + pos.y * this.image.scale.y
|
|
};
|
|
|
|
return framePos
|
|
}
|
|
|
|
/**
|
|
* Returns the coordinates for a screen point.
|
|
*/
|
|
coordinatesInWindow(point) {
|
|
let mapPos = {
|
|
x: (point.x - this.image.position.x) / this.image.scale.x,
|
|
y: (point.y - this.image.position.y) / this.image.scale.y
|
|
};
|
|
|
|
let coords = this.coordinatesFromPoint(mapPos);
|
|
return coords
|
|
}
|
|
|
|
removeFrame() {
|
|
this.frame = null;
|
|
}
|
|
|
|
setFrame(frame) {
|
|
if (this.debug) console.log('Set Frame: ', frame);
|
|
this.frame = frame;
|
|
}
|
|
|
|
/**
|
|
* Frame is the display in which the map is shown.
|
|
* Normally it's the app, but it can be another element,
|
|
* for example when in a submap.
|
|
*/
|
|
getFrame() {
|
|
let frame = {
|
|
x: 0,
|
|
y: 0,
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
|
|
if (this.frame) {
|
|
//The app does not have an x and y attribute.
|
|
//Therefore we need the additional check.
|
|
frame = {
|
|
x: this.frame.x ? this.frame.x : 0,
|
|
y: this.frame.y ? this.frame.y : 0,
|
|
width: this.frame.width,
|
|
height: this.frame.height
|
|
};
|
|
} else if (this.image.parent) {
|
|
let parent = this.image.parent;
|
|
|
|
for (let key of Object.keys(frame)) {
|
|
frame[key] = parent[key];
|
|
}
|
|
}
|
|
|
|
frame.localCenter = {
|
|
x: frame.width / 2,
|
|
y: frame.height / 2
|
|
};
|
|
|
|
frame.center = {
|
|
x: frame.x + frame.localCenter.x,
|
|
y: frame.y + frame.localCenter.y
|
|
};
|
|
|
|
return frame
|
|
}
|
|
|
|
/**
|
|
* Retrieves all maps from a json object.
|
|
*
|
|
* @static
|
|
* @param {object} json - The parsed map data object.
|
|
* @returns {object} - Returns an object with the names as keys and the GeoMaps as value.
|
|
* @memberof GeoMap
|
|
*/
|
|
static allFromJson(json, root = './') {
|
|
let error = { message: '' };
|
|
let maps = {};
|
|
if (GeoMap._validateJson(json, error)) {
|
|
for (let [mapname, data] of Object.entries(json)) {
|
|
data.tiles.path = root + data.tiles.path;
|
|
maps[mapname] = GeoMap._createMap(data);
|
|
maps[mapname].name = mapname;
|
|
}
|
|
} else console.error('Could not validate JSON: ' + error.message);
|
|
|
|
return maps
|
|
}
|
|
|
|
/**
|
|
*Extracts a single map from a JSON map file.
|
|
*
|
|
* @static
|
|
* @param {string} map - Name of the map.
|
|
* @param {object} json - Parsed json object.
|
|
* @returns {GeoMap} - Returns a geomap, if the map was in the data and valid. Otherwise false.
|
|
* @memberof GeoMap
|
|
*/
|
|
static mapFromJson(map, json) {
|
|
if (json[map]) {
|
|
const data = json[map];
|
|
if (this._validJsonMap(data)) return GeoMap._createMap(data)
|
|
else console.error('Map was not in a valid format.');
|
|
} else console.error('Map was not in data.');
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Validates if the map data contains valid data
|
|
* for creating the maps.
|
|
*
|
|
* @static
|
|
* @param {object} json - The object containing multiple map data sets.
|
|
* @param {error-object} error - An object that contains an parameter message: {message = ""}. This is faking a call by reference.
|
|
* @returns {boolean} - True if all sets were valid. False otherwise.
|
|
* @memberof GeoMap
|
|
*/
|
|
static _validateJson(json, error) {
|
|
let isValid = true;
|
|
|
|
if (Object.keys(json).length == 0) {
|
|
isValid = false;
|
|
error.message += 'The provided JSON object did not contain any items.';
|
|
}
|
|
for (let [name, data] of Object.entries(json)) {
|
|
if (!GeoMap._validJsonMap(data)) {
|
|
error.message += `${name} was not valid. `;
|
|
isValid = false;
|
|
}
|
|
}
|
|
|
|
if (error.message != '') error.message = 'Maps could not be validated: ' + error.message;
|
|
|
|
return isValid
|
|
}
|
|
|
|
static changePathsInJson(json, path) {
|
|
for (let key of Object.keys(json)) {
|
|
json[key].icon = path + json[key].icon;
|
|
json[key].tiles.path = path + json[key].tiles.path;
|
|
}
|
|
}
|
|
|
|
/**
|
|
*Validates of a single data set contains the valid data for creating a map.
|
|
*
|
|
* @static
|
|
* @param {object} json - The object containing a single set of map data.
|
|
* @returns {boolean} - True if valid, otherwise false.
|
|
* @memberof GeoMap
|
|
*/
|
|
static _validJsonMap(json) {
|
|
let isJsonValid = true;
|
|
const required = ['projection', 'type', 'icon'];
|
|
for (const requirement of Object.values(required)) {
|
|
if (!json.hasOwnProperty(requirement)) {
|
|
isJsonValid = false;
|
|
break
|
|
}
|
|
}
|
|
|
|
if (isJsonValid && json.type.toLowerCase() == 'deepzoom') {
|
|
if (!json.hasOwnProperty('tiles')) isJsonValid = false;
|
|
}
|
|
|
|
return isJsonValid
|
|
}
|
|
|
|
static _createMap(data) {
|
|
switch (data.type.toLowerCase()) {
|
|
case 'deepzoom':
|
|
return GeoMap._createDeepZoomMap(data)
|
|
default:
|
|
console.error(`Datatype is invalid or not implemented yet: ${data.type}`);
|
|
}
|
|
}
|
|
|
|
static _createDeepZoomMap(data) {
|
|
const projection = GeoMap._getProjectionByName(data.projection);
|
|
const tilesConfig = data.tiles;
|
|
const options = data.options;
|
|
|
|
const mapdata = new DeepZoomMapData(projection, tilesConfig);
|
|
return new DeepZoomMap(mapdata, tilesConfig, options)
|
|
}
|
|
|
|
static _getProjectionByName(projection) {
|
|
switch (projection.toLowerCase()) {
|
|
case 'mercator':
|
|
return new Mercator()
|
|
default:
|
|
console.error(`Projection is invalid or not implemented yet ${projection}.`);
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
GeoMap.counter = 0;
|
|
|
|
/**
|
|
* The DeepZoomMap class extends the GeoMap to create
|
|
* maps as deepzoom images from maptiles.
|
|
*
|
|
* @extends GeoMap
|
|
* @class
|
|
* @see {@link maps.html}
|
|
*/
|
|
|
|
class DeepZoomMap extends GeoMap {
|
|
/**
|
|
* @constructor
|
|
* @param {object} tilesConfig - The tiles config object, that defines at what path and in which format the tiles are.
|
|
* @param {MapData} mapdata - A MapData object, that contains informations of how the given map has to be interpreted.
|
|
* @param {object} opts - Additional options to specify the behaviour of the deep zoom image.
|
|
*/
|
|
constructor(mapdata, tilesConfig, opts = {}) {
|
|
opts = Object.assign(
|
|
{
|
|
maxScale: Math.min(tilesConfig.width, tilesConfig.height) / tilesConfig.tileSize,
|
|
minScale: mapdata.getMinScale,
|
|
highResolution: true,
|
|
debug: false
|
|
},
|
|
opts
|
|
);
|
|
super(mapdata, opts);
|
|
|
|
this.tilesConfig = tilesConfig;
|
|
this._verifyMapdata();
|
|
}
|
|
|
|
_verifyMapdata() {
|
|
if (!(this.mapdata instanceof MapData)) {
|
|
console.error('Use the MapData object for creating maps!');
|
|
} else {
|
|
if (!(this.mapdata instanceof DeepZoomMapData)) {
|
|
console.error('Use the DeepZoomMapData object.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the DeepZoomImage.
|
|
*
|
|
* @private
|
|
*/
|
|
load(container = null, scatter = null) {
|
|
if (!this.mapdata.app) console.error('App was not set in the mapdata.');
|
|
this.info = new DeepZoomInfo(this.tilesConfig);
|
|
let image = new DeepZoomImage(this.info, {
|
|
app: this.mapdata.app,
|
|
alpha: this.alpha,
|
|
debug: this.debug,
|
|
world: scatter == null ? scatter : scatter.getWorldScatter()
|
|
});
|
|
|
|
super.load(image, container, scatter);
|
|
|
|
if (this.debug) console.log('Loaded image: ', image, 'With options: ', this.info);
|
|
}
|
|
|
|
unloadImage() {
|
|
if (this.image) {
|
|
this.image.deactivate();
|
|
}
|
|
}
|
|
|
|
reloadImage() {
|
|
this.image.activate();
|
|
}
|
|
|
|
/**
|
|
* Event function that is invoked by the deep zoom image, when it has been transformed.
|
|
*
|
|
* @private
|
|
* @param {ScatterEvent} e
|
|
*/
|
|
transformed(e) {
|
|
this.image.transformed(e);
|
|
super.transformed(e);
|
|
}
|
|
|
|
/**
|
|
* Jumps to a certain point on the map and adjusts the size of the
|
|
* map depending on the distance (zoom).
|
|
*
|
|
*
|
|
* @param {*} coordinates
|
|
* @param {*} [zoom=null]
|
|
* @param {*} [{ animate = 0 }={}]
|
|
* @memberof DeepZoomMap
|
|
*/
|
|
moveTo(coordinates, zoom = null, { animate = 0 } = {}) {
|
|
if (this.image.scatter == null) {
|
|
return
|
|
}
|
|
//TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018
|
|
if (zoom) {
|
|
/**
|
|
* Zoom's the view to a specified distance in DZI-Levels.
|
|
* The input level is clipped to allowed levels.
|
|
* */
|
|
let targetScale = this.scaleForFloatingLevel(zoom);
|
|
|
|
// Don't scale beyond boundaries.
|
|
if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale);
|
|
if (this.minScale) targetScale = Math.max(targetScale, this.minScale);
|
|
|
|
this.image.scatter.requestScale(targetScale);
|
|
}
|
|
|
|
if (coordinates) {
|
|
//Get pixel position of lat lang values
|
|
let point = this.coordinatesToPoint(coordinates);
|
|
|
|
let containerCenter;
|
|
if (this.frame) {
|
|
containerCenter = this.getFrame().localCenter;
|
|
} else {
|
|
containerCenter = {
|
|
x: this.image.parent.width / 2,
|
|
y: this.image.parent.height / 2
|
|
};
|
|
}
|
|
|
|
//Calculate the delta depending on image position and center the
|
|
// desired position in the frame.
|
|
let delta = {
|
|
x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x,
|
|
y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y
|
|
};
|
|
|
|
if (animate > 0) {
|
|
let last = 0;
|
|
TweenMax.to({ x: 0, y: 0 }, animate, {
|
|
ease: Power0.easeNone,
|
|
x: delta.x,
|
|
y: delta.y,
|
|
onUpdateParams: ['{self}'],
|
|
onUpdate: tween => {
|
|
let step = tween.ratio - last;
|
|
last = tween.ratio;
|
|
this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 });
|
|
}
|
|
});
|
|
} else {
|
|
this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 });
|
|
}
|
|
}
|
|
// return this.image.scatter.position
|
|
}
|
|
|
|
scaleForFloatingLevel(level) {
|
|
level = Math.max(0, level - this.image.info.baseLevel);
|
|
return Math.pow(2, level) / this.image.resolution
|
|
}
|
|
|
|
floatingLevelForScale(scale) {
|
|
let level = Math.log2(scale * this.image.resolution);
|
|
let newLevel = this.image.info.baseLevel + Math.max(level, 0);
|
|
return Math.min(newLevel, this.image.info.maxLoadableLevel)
|
|
}
|
|
|
|
get distance() {
|
|
return this.floatingLevelForScale(this.scatter.scale)
|
|
}
|
|
|
|
clone(container, scatter = null) {
|
|
const map = new DeepZoomMap(this.mapdata, this.tilesConfig, {
|
|
alpha: this.alpha,
|
|
cover: this.cover,
|
|
debug: this.debug,
|
|
startScale: this.startScale,
|
|
minScale: this.minScale,
|
|
maxScale: this.maxScale,
|
|
rotatable: this.rotatable,
|
|
translatable: this.translatable,
|
|
scalable: this.scalable,
|
|
viewport: this.viewport
|
|
});
|
|
|
|
return map
|
|
}
|
|
|
|
tint() {
|
|
let color = DeepZoomMap.tintcolors[DeepZoomMap.tintcolor++ % DeepZoomMap.tintcolors.length];
|
|
|
|
this._forEachTile(tile => {
|
|
tile.tint = color;
|
|
});
|
|
}
|
|
|
|
_forEachTile(callback) {
|
|
this.image.children[0].children.forEach(tiles => {
|
|
tiles.children.forEach(callback);
|
|
});
|
|
}
|
|
|
|
setAlpha(alpha) {
|
|
this._forEachTile(tile => {
|
|
tile.alpha = alpha;
|
|
});
|
|
}
|
|
|
|
// /** Returns the tile layer level that corresponds to the given scale.
|
|
// * @param {number} scale - the scale factor
|
|
// **/
|
|
// levelForScale(scale) {
|
|
// let level = Math.round(Math.log2(scale * this.resolution)) // Math.floor(Math.log2(event.scale))+1
|
|
// let newLevel = this.info.baseLevel + Math.max(level, 0)
|
|
// return Math.min(newLevel, this.info.maxLoadableLevel)
|
|
// }
|
|
|
|
// /**
|
|
// * Returns the scale factor that correspond to a given level.
|
|
// * @param {number} level - the requested level
|
|
// * @returns {number} - Returns the scale level for the given level.
|
|
// */
|
|
// scaleForLevel(level){
|
|
// level = Math.max(0, level - this.info.baseLevel)
|
|
// return Math.pow(2, level) / this.resolution
|
|
// }
|
|
}
|
|
|
|
DeepZoomMap.tintcolors = [0xff0000, 0xff00ff, 0xffff00, 0x00ff00, 0x00ffff, 0x0000ff];
|
|
DeepZoomMap.tintcolor = 0;
|
|
|
|
/**
|
|
*
|
|
* ImageMap extends GeoMap to display simple images
|
|
* as maps.
|
|
*/
|
|
|
|
class ImageMap extends GeoMap {
|
|
constructor(sprite, mapdata, opts = {}) {
|
|
super(mapdata, opts);
|
|
if (this.debug) console.log('Construct Image Map', sprite, mapdata, opts);
|
|
|
|
this.sprite = sprite;
|
|
|
|
this.baseZoomHeight = opts.baseZoomHeight ? opts.baseZoomHeight : this.sprite.texture.height;
|
|
}
|
|
|
|
load(container = null, scatter = null) {
|
|
super.load(this.sprite, container, scatter);
|
|
this.image.alpha = this.alpha;
|
|
this.image.interactive = true;
|
|
}
|
|
|
|
clone(container = null, scatter = null) {
|
|
const map = new ImageMap(new PIXI.Sprite(this.sprite.texture), this.mapdata, {
|
|
alpha: this.alpha,
|
|
cover: this.cover,
|
|
debug: this.debug,
|
|
startScale: this.startScale,
|
|
minScale: this.minScale,
|
|
maxScale: this.maxScale,
|
|
rotatable: this.rotatable,
|
|
translatable: this.translatable,
|
|
scalable: this.scalable,
|
|
viewport: this.viewport
|
|
});
|
|
map.load(container, scatter);
|
|
|
|
return map
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @param {*} coordinates
|
|
* @param {*} [zoom=null]
|
|
* @param {*} [{ animate = 0 }={}]
|
|
* @memberof ImageMap
|
|
*/
|
|
moveTo(coordinates, zoom = null, { animate = 0 } = {}) {
|
|
if (this.image.scatter == null) {
|
|
return
|
|
}
|
|
//TODO: This is jaggy on submaps. Make it more smooth. Severin Opel 08-05-2018
|
|
if (zoom) {
|
|
/**
|
|
* Zoom's the view to a specified distance in DZI-Levels.
|
|
* The input level is clipped to allowed levels.
|
|
* */
|
|
|
|
let targetScale = zoom * this.zoomFactor;
|
|
|
|
// Don't scale beyond boundaries.
|
|
if (this.maxScale) targetScale = Math.min(this.maxScale, targetScale);
|
|
|
|
if (this.minScale) targetScale = Math.max(targetScale, this.minScale);
|
|
|
|
this.image.scatter.requestScale(targetScale);
|
|
}
|
|
|
|
//coordinates = null
|
|
if (coordinates) {
|
|
//Get pixel position of lat lang values
|
|
let point = this.coordinatesToPoint(coordinates);
|
|
|
|
let containerCenter;
|
|
if (this.frame) {
|
|
containerCenter = this.getFrame().localCenter;
|
|
} else {
|
|
containerCenter = {
|
|
x: this.image.parent.width / 2,
|
|
y: this.image.parent.height / 2
|
|
};
|
|
}
|
|
|
|
//Calculate the delta depending on image position and center the
|
|
// desired position in the frame.
|
|
|
|
let delta = {
|
|
x: containerCenter.x - point.x * this.image.scatter.scale - this.scatter.position.x,
|
|
y: containerCenter.y - point.y * this.image.scatter.scale - this.scatter.position.y
|
|
};
|
|
|
|
if (animate > 0) {
|
|
let last = 0;
|
|
TweenMax.to({ x: 0, y: 0 }, animate, {
|
|
ease: Power0.easeNone,
|
|
x: delta.x,
|
|
y: delta.y,
|
|
onUpdateParams: ['{self}'],
|
|
onUpdate: tween => {
|
|
let step = tween.ratio - last;
|
|
last = tween.ratio;
|
|
this.image.scatter.transform(Points.multiplyScalar(delta, step), /*ratio*/ 1, 0, { x: 0, y: 0 });
|
|
}
|
|
});
|
|
} else {
|
|
this.image.scatter.transform(delta, /*ratio*/ 1, 0, { x: 0, y: 0 });
|
|
}
|
|
}
|
|
}
|
|
|
|
get zoom() {
|
|
return this.scatter.scale / this.zoomFactor
|
|
}
|
|
|
|
get zoomFactor() {
|
|
let factor = this.baseZoomHeight / this.sprite.texture.height;
|
|
return factor
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The MapView class is responsible for a consistent map view.
|
|
* It is aware of the current viewposition, the scale and viewport.
|
|
* It ensures, that maps can be changed, without the user noticing it.
|
|
*
|
|
*/
|
|
class MapView {
|
|
/**
|
|
*
|
|
* @param {object} [focus = {x:0, y:0}] - Defines the startup focuspoint of the app.
|
|
* @param {number} [zoom = 0] - Defines the startup zoom of the app. Note that this is just a request.
|
|
* The MapView will prioritize a full scale app, than displaying the demanded zoom factor
|
|
*/
|
|
constructor({ focus = null, zoom = null, viewport = { min: { x: -85, y: -180 }, max: { x: 85, y: 180 } } } = {}) {
|
|
this.viewport = viewport;
|
|
this._focus = focus;
|
|
this._zoom = zoom;
|
|
this.referenceHeight = 256;
|
|
}
|
|
|
|
get focus() {
|
|
return this._focus
|
|
}
|
|
get zoom() {
|
|
return this._zoom
|
|
}
|
|
|
|
apply(map) {
|
|
map.moveTo(this._focus, this._zoom);
|
|
}
|
|
|
|
transformed(map) {
|
|
this.updateZoom(map);
|
|
this.updateFocusPoint(map);
|
|
}
|
|
|
|
updatePosition(map) {
|
|
this.updateFocusPoint(map);
|
|
this.updateZoom(map);
|
|
}
|
|
|
|
updateFocusPoint(map) {
|
|
const frame = map.getFrame();
|
|
this._focus = this.coordinatesFromWindowPoint(map, frame.localCenter);
|
|
}
|
|
|
|
updateZoom(map) {
|
|
/**
|
|
* TODO: This relies on the fact, that all maps have the same tileSize,
|
|
* if a set would have a smaller tileSize. Improve that.
|
|
*/
|
|
if (map instanceof DeepZoomMap) this._zoom = map.floatingLevelForScale(map.image.scatter.scale);
|
|
else {
|
|
this._zoom = map.zoom;
|
|
}
|
|
}
|
|
|
|
mapPointToWindowPoint(map, point) {
|
|
let windowPoint = { x: 0, y: 0 };
|
|
|
|
if (map['image'] && map.image['parent']) {
|
|
let container = map.image.parent;
|
|
|
|
windowPoint = new PIXI.Point(
|
|
map.scatter.position.x + map.scatter.scale * point.x,
|
|
map.scatter.position.y + map.scatter.scale * point.y
|
|
);
|
|
|
|
windowPoint = container.toGlobal(windowPoint);
|
|
} else {
|
|
console.error(this._noParentError);
|
|
}
|
|
|
|
return windowPoint
|
|
}
|
|
|
|
windowPointToMapPoint(map, point) {
|
|
let pointOnMap = { x: 0, y: 0 };
|
|
if (map['image'] && map.image['parent']) {
|
|
let offset = map.image.parent.toGlobal({ x: 0, y: 0 });
|
|
pointOnMap = new PIXI.Point(
|
|
(point.x - map.scatter.position.x - offset.x) / map.scatter.scale,
|
|
(point.y - map.scatter.position.y - offset.y) / map.scatter.scale
|
|
);
|
|
} else console.error(this._noParentError);
|
|
|
|
return pointOnMap
|
|
}
|
|
|
|
get _noParentError() {
|
|
return 'Cannot compute map point when map has no parent.'
|
|
}
|
|
|
|
/**
|
|
* Gets the coordinates of a specific point in the viewport.
|
|
*
|
|
* @param {PIXI.Point | {x,y}} point - Pixel position in the viewport.
|
|
* @returns {{x,y}} Coordinates on the map of the provided position.
|
|
* @memberof MapView
|
|
*/
|
|
coordinatesFromWindowPoint(map, point) {
|
|
let position = {
|
|
x: point.x - map.scatter.position.x,
|
|
y: point.y - map.scatter.position.y
|
|
};
|
|
|
|
let normalized = {
|
|
x: position.x / (map.width * map.scatter.scale),
|
|
y: position.y / (map.height * map.scatter.scale)
|
|
};
|
|
|
|
let coordinates = map.mapdata.toCoordinates(normalized);
|
|
|
|
return coordinates
|
|
}
|
|
}
|
|
|
|
/* */
|
|
|
|
class Robinson extends Projection {
|
|
constructor(lng = 0) {
|
|
super();
|
|
|
|
this.lng0 = lng;
|
|
this.lengthOfParallel = [
|
|
1.0,
|
|
0.9986,
|
|
0.9954,
|
|
0.99,
|
|
0.9822,
|
|
0.973,
|
|
0.96,
|
|
0.9427,
|
|
0.9216,
|
|
0.8962,
|
|
0.8679,
|
|
0.835,
|
|
0.7986,
|
|
0.7597,
|
|
0.7186,
|
|
0.6732,
|
|
0.6213,
|
|
0.5722,
|
|
0.5322
|
|
];
|
|
this.distancesFromEquator = [
|
|
0.0,
|
|
0.062,
|
|
0.124,
|
|
0.186,
|
|
0.248,
|
|
0.31,
|
|
0.372,
|
|
0.434,
|
|
0.4958,
|
|
0.5571,
|
|
0.6176,
|
|
0.6769,
|
|
0.7346,
|
|
0.7903,
|
|
0.8435,
|
|
0.8936,
|
|
0.9394,
|
|
0.9761,
|
|
1.0
|
|
];
|
|
}
|
|
forward(coords) {
|
|
let { x: lat, y: lng } = coords;
|
|
|
|
lng = this._adjustLng(lng);
|
|
|
|
// Get the required indices, the remainder in between low and hight as ratio
|
|
// and the sign of the found indices, as the tables are only in positive direction.
|
|
let { low, high, ratio, sign } = this._getInterpolationValues(lat, 90);
|
|
|
|
// Values that lie inbetween two indices are interpolated.
|
|
let y = this._interpolate(this.distancesFromEquator[low], this.distancesFromEquator[high], ratio);
|
|
|
|
// Reapply the sign to the vertical position.
|
|
y *= sign;
|
|
|
|
// The center of the projection is in the center of the map. Therefore we shift the
|
|
// center to the top left corner.
|
|
y = 1 - (y + 1) / 2;
|
|
|
|
// The lengthOfParallel table provides us with the corresponding scaling factor
|
|
// for a specific latitude. Inbetween values are interpolated as before.
|
|
let proportionalLength = this._interpolate(this.lengthOfParallel[low], this.lengthOfParallel[high], ratio);
|
|
|
|
//To normalize the value to a range from -1 to 1.
|
|
let x = (proportionalLength * lng) / 180;
|
|
|
|
x = (x + 1) / 2;
|
|
|
|
return { x, y }
|
|
}
|
|
|
|
backward(position) {
|
|
let { x, y } = position;
|
|
|
|
y = 1 - 2 * y;
|
|
let sign = Math.sign(y);
|
|
y = Math.abs(y);
|
|
|
|
let low = 0;
|
|
let high = 0;
|
|
for (let i = 0; i < this.distancesFromEquator.length - 1 && y > this.distancesFromEquator[i]; i++) {
|
|
low = i;
|
|
high = i + 1;
|
|
}
|
|
|
|
let lowDist = this.distancesFromEquator[low];
|
|
let highDist = this.distancesFromEquator[high];
|
|
|
|
let ratio = highDist - lowDist == 0 ? 0 : (y - lowDist) / (highDist - lowDist);
|
|
|
|
let lat = low * 5 + ratio * 5;
|
|
|
|
let parallelLengthMin = this.lengthOfParallel[low];
|
|
let parallelLengthMax = this.lengthOfParallel[high];
|
|
|
|
let completeLength = parallelLengthMin + (parallelLengthMax - parallelLengthMin) * ratio;
|
|
|
|
x = x * 2 - 1;
|
|
let normalizedLength = x / completeLength;
|
|
|
|
let lng = normalizedLength * 180;
|
|
|
|
return { x: lat * sign, y: this._adjustLng(lng, true) }
|
|
}
|
|
|
|
_adjustLng(lng, inv = false) {
|
|
let moved = inv ? lng + this.lng0 : lng - this.lng0;
|
|
// if (moved < -180) moved += 360
|
|
// if (moved > 180) moved -= 360
|
|
|
|
return moved
|
|
}
|
|
|
|
_interpolate(a, b, ratio) {
|
|
return a * (1 - ratio) + b * ratio
|
|
}
|
|
_getInterpolationValues(value, max) {
|
|
let sign = Math.sign(value);
|
|
value = Math.min(Math.abs(value), max);
|
|
// Note that min and max can be the same. Which is true
|
|
// when lat is dividable by 5. This also covers the edge cases 0 and 90.
|
|
let minIndex = Math.floor(value / 5);
|
|
let maxIndex = Math.ceil(value / 5);
|
|
let ratio = (value % 5) / 5;
|
|
// console.log({ value, minIndex, maxIndex, ratio })
|
|
// console.log(this.lengthOfParallel.length)
|
|
return { low: minIndex, high: maxIndex, ratio, sign }
|
|
}
|
|
|
|
get name() {
|
|
return 'Robinson Projection'
|
|
}
|
|
|
|
get maxViewport() {
|
|
let min = new PIXI.Point(-90, -180);
|
|
let max = new PIXI.Point(90, 180);
|
|
|
|
max.x += this.lng0;
|
|
min.x += this.lng0;
|
|
|
|
console.log({ min, max });
|
|
return { min, max }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The class CoordinateDisplay shows the coordinates of the center of the sceen
|
|
* in the top left of the renderer.
|
|
*
|
|
* @private
|
|
* @class
|
|
* @extends PIXI.Graphics
|
|
* @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
|
|
*/
|
|
class CoordinateDisplay extends PIXI.Graphics {
|
|
/**
|
|
* Creates an instance of a CoordinateDisplay.
|
|
*
|
|
* @constructor
|
|
* @param {MapApp} mapApp - The MapApp where the coordinates should be displayed.
|
|
*/
|
|
constructor(mapApp) {
|
|
super();
|
|
|
|
this.app = mapApp;
|
|
|
|
this.crosshair = new PIXI.Graphics();
|
|
|
|
this.outerSize = 15;
|
|
this.innerSize = 5;
|
|
this.thickness = 2;
|
|
this.color = 0xe73230;
|
|
|
|
this.crosshair.lineStyle(this.thickness, this.color);
|
|
this.crosshair.moveTo(-this.outerSize, 0);
|
|
this.crosshair.lineTo(-this.innerSize, 0);
|
|
this.crosshair.moveTo(this.innerSize, 0);
|
|
this.crosshair.lineTo(this.outerSize, 0);
|
|
|
|
this.crosshair.moveTo(0, -this.outerSize);
|
|
this.crosshair.lineTo(0, -this.innerSize);
|
|
this.crosshair.moveTo(0, this.innerSize);
|
|
this.crosshair.lineTo(0, this.outerSize);
|
|
|
|
this.text = new PIXI.Text(
|
|
'Pending ...',
|
|
new PIXI.TextStyle({
|
|
fontFamily: 'Arial',
|
|
fontSize: 14,
|
|
fontWeight: 'bold',
|
|
fill: '#f6f6f6',
|
|
stroke: '#434f4f',
|
|
strokeThickness: 3
|
|
})
|
|
);
|
|
|
|
this.refreshCoordinates();
|
|
|
|
this.yOffset = 20;
|
|
this.volume = 32;
|
|
this.margin = 5;
|
|
|
|
this._updateFrame();
|
|
|
|
this.text.position.set(20 + this.margin, this.yOffset + this.volume + 2 * this.margin);
|
|
|
|
this.addChild(this.text);
|
|
this.addChild(this.crosshair);
|
|
this.setCrosshair();
|
|
|
|
window.setInterval(this.refreshCoordinates.bind(this), 300);
|
|
}
|
|
|
|
_updateFrame() {
|
|
this.clear();
|
|
this.lineStyle(3, 0x434f4f, 1)
|
|
.beginFill(0x434f4f, 0.6)
|
|
.drawRoundedRect(20, this.yOffset + this.volume + this.margin, this.text.width + 2 * this.margin, 32, 5)
|
|
.endFill();
|
|
}
|
|
|
|
toggleCrosshair() {
|
|
if (this.crosshair.parent) this.disableCrosshair();
|
|
else this.enableCrosshair();
|
|
}
|
|
|
|
enableCrosshair() {
|
|
this.addChild(this.crosshair);
|
|
}
|
|
|
|
disableCrosshair() {
|
|
this.removeChild(this.crosshair);
|
|
}
|
|
|
|
/**
|
|
* Refreshes displayed coordinates.
|
|
*
|
|
* @return {MapApp} Returns the MapApp object for chaining.
|
|
*/
|
|
refreshCoordinates() {
|
|
if (this.app.mapLayer.mapview.focus && this.app.mapLayer.mapview.zoom) {
|
|
//TODO pass a mapview to avoid global access.
|
|
var coords = this.app.mapLayer.mapview.focus;
|
|
this.setCrosshair();
|
|
this.text.text =
|
|
'Lat: ' +
|
|
coords.x.toFixed(4) +
|
|
'| Lng: ' +
|
|
coords.y.toFixed(4) +
|
|
'| Zoom: ' +
|
|
this.app.mapLayer.mapview.zoom.toFixed(2);
|
|
this._updateFrame();
|
|
}
|
|
}
|
|
setCrosshair() {
|
|
this.crosshair.position.set(this.app.center.x, this.app.center.y);
|
|
}
|
|
}
|
|
|
|
/* globals PIXI */
|
|
|
|
class FlagType {
|
|
static get bottomLeft() {
|
|
return { x: 1, y: -1 }
|
|
}
|
|
static get bottomRight() {
|
|
return { x: -1, y: -1 }
|
|
}
|
|
static get topLeft() {
|
|
return { x: -1, y: 1 }
|
|
}
|
|
static get topRight() {
|
|
return { x: 1, y: 1 }
|
|
}
|
|
|
|
static toString(flagType) {
|
|
let str = '';
|
|
if (flagType.x && flagType.y) {
|
|
if (flagType.y == 1) str += 'bottom';
|
|
else if (flagType.y == -1) str += 'top';
|
|
else str += '_INVALID_Y_';
|
|
|
|
if (flagType.x == 1) str += 'Right';
|
|
else if (flagType.x == -1) str += 'Left';
|
|
else str += '_INVALID_X_';
|
|
} else str = 'Invalid FlagType: ' + flagType.toString();
|
|
|
|
return str
|
|
}
|
|
}
|
|
|
|
class FlagPolygon extends PIXI.Polygon {
|
|
constructor({
|
|
type = FlagType.bottomLeft,
|
|
width = 100,
|
|
height = 30,
|
|
notchSize = 10,
|
|
notchWidth = null,
|
|
notchHeight = null,
|
|
originOffset = { x: 0, y: 0 }
|
|
} = {}) {
|
|
let points = [];
|
|
|
|
let dimensions = { x: width, y: height };
|
|
dimensions = Points.multiply(dimensions, type);
|
|
|
|
notchWidth = notchWidth == null ? notchSize : notchWidth;
|
|
notchHeight = notchHeight == null ? notchSize : notchHeight;
|
|
|
|
notchSize = { x: notchWidth, y: notchHeight };
|
|
notchSize = Points.multiply(notchSize, type);
|
|
|
|
originOffset = Points.multiply(originOffset, type);
|
|
|
|
let point = new PIXI.Point(originOffset.x, originOffset.y);
|
|
points.push(point.clone());
|
|
|
|
point.y += notchSize.y;
|
|
points.push(point.clone());
|
|
|
|
point.y += dimensions.y;
|
|
points.push(point.clone());
|
|
|
|
point.x += dimensions.x;
|
|
points.push(point.clone());
|
|
|
|
point.y -= dimensions.y;
|
|
points.push(point.clone());
|
|
|
|
point.x -= dimensions.x - notchSize.x;
|
|
points.push(point.clone());
|
|
|
|
// close polygon
|
|
points.push(points[0].clone());
|
|
|
|
super(points);
|
|
|
|
this.type = type;
|
|
this.dimensions = dimensions;
|
|
this.notchSize = notchSize;
|
|
this.originOffset = originOffset;
|
|
}
|
|
|
|
getPoint(i) {
|
|
if (i >= 0) {
|
|
let idx = i * 2;
|
|
return [this.points[idx], this.points[idx + 1]]
|
|
} else {
|
|
let idx = (Math.floor(this.points.length / 2) + i) * 2;
|
|
return [this.points[idx], this.points[idx + 1]]
|
|
}
|
|
}
|
|
|
|
get notch() {
|
|
let points = [this.getPoint(0), this.getPoint(1), this.getPoint(-2), this.getPoint(0)];
|
|
let notchPolygon = [];
|
|
|
|
points.forEach(point => {
|
|
notchPolygon = notchPolygon.concat(point);
|
|
});
|
|
|
|
return notchPolygon
|
|
}
|
|
get rect() {
|
|
let points = [this.getPoint(1), this.getPoint(2), this.getPoint(3), this.getPoint(4), this.getPoint(1)];
|
|
|
|
let rectPolygon = [];
|
|
points.forEach(point => {
|
|
rectPolygon = rectPolygon.concat(point);
|
|
});
|
|
return rectPolygon
|
|
}
|
|
|
|
placeText(text, padding) {
|
|
text.position = Points.add(this.originOffset, { x: 0, y: this.notchSize.y });
|
|
padding = Points.multiply(padding, this.type);
|
|
text.position = Points.add(text.position, padding);
|
|
|
|
if (this.type.y == -1) text.position.y -= text.height;
|
|
|
|
if (this.type.x == -1) text.position.x -= text.width;
|
|
}
|
|
}
|
|
|
|
class Flag extends PIXI.Graphics {
|
|
constructor(
|
|
{
|
|
type = FlagType.bottomLeft,
|
|
width = 100,
|
|
height = 30,
|
|
notchSize = 10,
|
|
notchWidth = null,
|
|
notchHeight = null,
|
|
originOffset = { x: 0, y: 0 }
|
|
} = {},
|
|
nativeLines = false
|
|
) {
|
|
super(nativeLines);
|
|
|
|
this.flagPolygon = new FlagPolygon({
|
|
type,
|
|
width,
|
|
height,
|
|
notchSize,
|
|
notchWidth,
|
|
notchHeight,
|
|
originOffset
|
|
});
|
|
|
|
this.draw();
|
|
}
|
|
|
|
draw() {
|
|
this.drawPolygon(this.flagPolygon);
|
|
}
|
|
|
|
get typeName() {
|
|
return FlagType.toString(this.type)
|
|
}
|
|
}
|
|
|
|
class Label extends PIXI.Graphics {
|
|
constructor(text, textStyle = new PIXI.TextStyle(), nativeLines = false) {
|
|
super(nativeLines);
|
|
this._text = new PIXI.Text(text, textStyle);
|
|
this.addChild(this._text);
|
|
}
|
|
|
|
get text() {
|
|
return this._text
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GeoGraphics are graphical objects, that does not store the graphics information
|
|
* in screen space, but in geographical coordinates. Therefore GeoGraphics must be
|
|
* placed on GeoLayers to work properly.
|
|
*
|
|
* (Note: As GeoLayers are always children of a map layer. When the map is changed
|
|
* all GeoLayers are notified via the 'adaptTo(map)' method.)
|
|
*
|
|
* The geolayers forward this 'adaptTo' to all children that are GeoGraphics.
|
|
* Which adjust their so called 'point' data to the new map.
|
|
*
|
|
* @abstract
|
|
*/
|
|
class GeoGraphics {
|
|
constructor(coordinates, { scale = 1, onDraw = null, onDrawEnd = null, debug = false } = {}) {
|
|
this.coordinates = coordinates;
|
|
this.debug = debug;
|
|
this.graphics = new PIXI.Graphics();
|
|
this.scale = scale;
|
|
this.drawHandler = new EventHandler('onDraw', { listeners: onDraw });
|
|
this.drawEndHandler = new EventHandler('onDrawEnd', { listeners: onDrawEnd });
|
|
this._points = null;
|
|
this._position = null;
|
|
}
|
|
|
|
clone() {
|
|
console.error(`Call of abstract method clone(). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
_cloneOptions() {
|
|
return {
|
|
debug: this.debug,
|
|
scale: this.scale
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The _adaptCoordinates is called first by the adaptTo Method.
|
|
* Here all coordinates are transformed into point coordinates.
|
|
* This must be overloaded in subclass.
|
|
*
|
|
* @abstract
|
|
*/
|
|
_adaptCoordinates(map) {
|
|
console.error(`Call of abstract method _adaptCoordinates(map). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* Gets all screen points in a single array.
|
|
*
|
|
* @abstract
|
|
* @returns {array} - Array of all points in the GeoGraphic.
|
|
*/
|
|
_getPoints() {
|
|
console.error(`Call of abstract method _getPoints(func). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* Manipulates all points depending on a function.
|
|
* Mainly used to transform points to local space.
|
|
*
|
|
* @abstract
|
|
* @param {function} func
|
|
* @memberof GeoGraphics
|
|
*/
|
|
_manipulatePoints(func) {
|
|
console.error(`Call of abstract method _manipulatePoints(func). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* The _draw method is called last on adaptation. It creates the GraphicData
|
|
* of the specified subclass. To manipulate the style of the graphic, hook an onDraw listener
|
|
* to the GeoGraphics object. It is called before the _draw and lets the user modify color and
|
|
* lineStyle of the drawn object.
|
|
*
|
|
* Note: It could also be used for more radical manipulations on the graphics object.
|
|
* But this should be used with care.
|
|
*
|
|
* @abstract
|
|
*/
|
|
_draw() {
|
|
console.error(`Call of abstract method _draw(). Overwrite in subclass.`, this);
|
|
}
|
|
|
|
/**
|
|
* Called by the containing geo layer, when the map changes.
|
|
*/
|
|
adaptTo(map) {
|
|
this._points = this._adaptCoordinates(map);
|
|
this._updatePosition();
|
|
this.draw();
|
|
}
|
|
|
|
/**
|
|
* Redraws the graphics.
|
|
*
|
|
* This should be only called if you require an redraw independent of an adapt.
|
|
*
|
|
* @memberof GeoGraphics
|
|
*/
|
|
draw() {
|
|
this._prepareDraw();
|
|
this.drawHandler.call(this, this.graphics);
|
|
this._draw();
|
|
this.drawEndHandler.call(this, this.graphics);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the point data.
|
|
* Note: This data changes on adaptation.
|
|
*/
|
|
get points() {
|
|
return this._points
|
|
}
|
|
|
|
/**
|
|
* Returns the screen position of the GeoGraphics.
|
|
*/
|
|
get position() {
|
|
return this._position
|
|
}
|
|
|
|
get layer() {
|
|
return this._layer ? this._layer : null
|
|
}
|
|
|
|
setLayer(layer) {
|
|
this._layer = layer;
|
|
}
|
|
|
|
get map() {
|
|
let map = null;
|
|
if (this.mapLayer) {
|
|
map = this.mapLayer.map;
|
|
}
|
|
return map
|
|
}
|
|
|
|
get mapLayer() {
|
|
let mapLayer = null;
|
|
if (this.layer) {
|
|
mapLayer = this.layer.mapLayer;
|
|
}
|
|
return mapLayer
|
|
}
|
|
|
|
/**
|
|
* Prepare draw is a private function, that prepares the graphics
|
|
* for the next draw call. It also fires the drawHandler.
|
|
*
|
|
* @private
|
|
* @memberof GeoGraphics
|
|
*/
|
|
_prepareDraw() {
|
|
this.graphics.clear();
|
|
/*
|
|
Set a fillcolor and a stroke style for
|
|
debugging. Can be overloaded using the onDraw
|
|
event function.
|
|
*/
|
|
if (this.debug) this.graphics.beginFill(0xff00ff);
|
|
}
|
|
|
|
_updatePosition() {
|
|
let points = this._getPoints();
|
|
this._position = GeoGraphics.calculateCenterOfMass(points);
|
|
this._manipulatePoints(point => {
|
|
point = Points.subtract(point, this._position);
|
|
return point
|
|
});
|
|
this.graphics.position = this._position;
|
|
}
|
|
|
|
/**
|
|
* Calculates the center of Mass for a set of points.
|
|
*
|
|
* @static
|
|
* @param {Array} points - Array of points in the format {x: a, y:b}
|
|
* @returns {object} - Returns a point containing the center of mass of the polygon.
|
|
* @memberof GeoGraphics
|
|
*/
|
|
static calculateCenterOfMass(points) {
|
|
let com = new PIXI.Point();
|
|
points.forEach(p => {
|
|
let point = new PIXI.Point(p.x, p.y);
|
|
com = Points.add(com, point);
|
|
});
|
|
return Points.multiplyScalar(com, 1 / points.length)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Represents a single point on the Map.
|
|
*
|
|
* This GeoGraphics does not provide any visual representation.
|
|
* Draw the desired shape in the onDraw callback.
|
|
*/
|
|
class GeoPoint extends GeoGraphics {
|
|
clone() {
|
|
return new GeoPoint(this.coordinates, this._cloneOptions())
|
|
}
|
|
|
|
_adaptCoordinates(map) {
|
|
let scale = 1;
|
|
|
|
if (map instanceof DeepZoomMap) {
|
|
scale = map.image.scale.x;
|
|
}
|
|
|
|
scale = scale / 4;
|
|
|
|
return map.coordinatesToPoint(this.coordinates)
|
|
}
|
|
|
|
_getPoints() {
|
|
return [this.points]
|
|
}
|
|
|
|
_manipulatePoints(func) {
|
|
this._points = func(this._points);
|
|
}
|
|
|
|
_draw() {}
|
|
}
|
|
|
|
class GeoLine extends GeoGraphics {
|
|
/**
|
|
* @param {object} opts - Optional values
|
|
* @param {array} [opts.points=[]] - Initial points of the geo shape.
|
|
* @param {boolean} [closed=false] - Defines if the
|
|
*/
|
|
constructor(coordinates, { closed = false, size = 1, onDraw = null } = {}) {
|
|
super(coordinates, {
|
|
size,
|
|
onDraw
|
|
});
|
|
|
|
this._closed = closed;
|
|
}
|
|
|
|
clone() {
|
|
return new GeoLine(this.coordinates, this._cloneOptions)
|
|
}
|
|
|
|
_cloneOptions() {
|
|
let options = super._cloneOptions();
|
|
Object.assign(options, {
|
|
closed: this.closed
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds a point to the geo line.
|
|
*/
|
|
addPoint(coordinate) {
|
|
this.coordinates.push(coordinate);
|
|
}
|
|
|
|
_manipulatePoints(func) {
|
|
this.points.forEach((point, idx, array) => {
|
|
array[idx] = func(point);
|
|
});
|
|
}
|
|
|
|
_getPoints() {
|
|
return this.points
|
|
}
|
|
|
|
_adaptCoordinates(map) {
|
|
let points = [];
|
|
this.coordinates.forEach(point => {
|
|
points.push(map.coordinatesToPoint(point));
|
|
});
|
|
return points
|
|
}
|
|
|
|
_prepareDraw() {
|
|
this.graphics.clear();
|
|
if (this.debug) this.graphics.lineStyle(0.5, 0xff00ff);
|
|
this.drawHandler.call(this);
|
|
}
|
|
|
|
_draw() {
|
|
/**
|
|
* This resets the fill.
|
|
*
|
|
* DISCUSS: SO: "I'm not sure how the line should be defined.
|
|
* On the one hand. The line is clearly intended to
|
|
* represent a line and not an area. On the other hand,
|
|
* why should the user be prevented from using a fill for the
|
|
* area within the line. But if he want's a fill, why don't take
|
|
* a Polygon in the first place?
|
|
*
|
|
* (But if it's a predefined GeoJSON object obtained through e.g. elasticsearch,
|
|
* then the user is not in full control of the object type and it may be a good
|
|
* addition to grant the user this additional design choice.)
|
|
*
|
|
* The opportunity to do so would result in additional conditions, when creating the
|
|
* GeoGraphics of an overlay."
|
|
* */
|
|
this.graphics.beginFill(0, 0);
|
|
|
|
if (this.points.length > 0) {
|
|
this.graphics.moveTo(this.points[0].x, this.points[0].y);
|
|
|
|
for (let i = 1; i < this.points.length; i++) {
|
|
this.graphics.lineTo(this.points[i].x, this.points[i].y);
|
|
}
|
|
|
|
if (this.closed) {
|
|
this.graphics.lineTo(this.points[0].x, this.points[0].y);
|
|
}
|
|
}
|
|
}
|
|
|
|
get closed() {
|
|
return this._closed
|
|
}
|
|
|
|
set closed(val) {
|
|
if (val != this._closed) {
|
|
this._closed = val;
|
|
this.draw();
|
|
}
|
|
}
|
|
}
|
|
|
|
class GeoShape extends GeoGraphics {
|
|
clone() {
|
|
return new GeoShape(this.coordinates, this._cloneOptions)
|
|
}
|
|
|
|
_manipulatePoints(func) {
|
|
this.constructor._manipulatePoints(this.points, func);
|
|
}
|
|
|
|
/**
|
|
* Mets the requirements for the _manipulatePointsMethod().
|
|
* Primarily used in subclasses to get the processing steps from
|
|
* their superclass.
|
|
*
|
|
* @static
|
|
* @protected
|
|
* @param {Array.<PIXI.Points>} points - The points array that shold be manipulated.
|
|
* @param {function} func - The function that changes the single point value. Has to return a new point.
|
|
* @memberof GeoShape
|
|
*/
|
|
static _manipulatePoints(points, func) {
|
|
points.forEach((pointArray, arrIdx) => {
|
|
pointArray.forEach((point, idx) => {
|
|
points[arrIdx][idx] = func(point);
|
|
});
|
|
});
|
|
}
|
|
|
|
_getPoints() {
|
|
return this.constructor._getPointsFrom(this.points)
|
|
}
|
|
|
|
/**
|
|
* Returns all points of a polygon array.
|
|
* Useful for when getting sub polygons in child class.
|
|
*/
|
|
static _getPointsFrom(shape) {
|
|
let concatArray = [];
|
|
shape.forEach(array => {
|
|
concatArray = concatArray.concat(array);
|
|
});
|
|
|
|
return concatArray
|
|
}
|
|
|
|
_adaptCoordinates(map) {
|
|
let val = this.constructor._adaptPoint(this.coordinates, map);
|
|
return val
|
|
}
|
|
|
|
static _adaptPoint(coordinates, map) {
|
|
return coordinates.map(array => {
|
|
return array.map(point => {
|
|
return map.coordinatesToPoint(point)
|
|
})
|
|
})
|
|
}
|
|
|
|
_draw() {
|
|
this._drawFrom(this.points);
|
|
this.graphics.position = this.position;
|
|
}
|
|
|
|
_drawFrom(shape) {
|
|
const { polygon, hole } = this.constructor._pointsToShape(shape);
|
|
this._drawShape(polygon, hole);
|
|
}
|
|
|
|
/**
|
|
* Separates the points array into it's two parts:
|
|
* - the solid polygon
|
|
* - a hole that is cut into the polygon (optional)
|
|
*
|
|
* @private
|
|
* @returns {object} - Returns an object containing the polygon and the hole in the form of {polygon: [...PIXI.Point], hole: [...PIXI.Point]}
|
|
*/
|
|
static _pointsToShape(points) {
|
|
let polygon = GeoShape._transformToPIXI(points[0]);
|
|
let hole = points[1] ? GeoShape._transformToPIXI(points[1]) : [];
|
|
return { polygon, hole }
|
|
}
|
|
|
|
/**
|
|
* Transform an array of poins into an array of PIXI.Points.
|
|
* Note: When creating PIXI.Polygons, for some reason the points
|
|
* need to be a PIXI.Points object (at least the first one).
|
|
*
|
|
* @param {array} points - Points in the form of {x:a,y:b}
|
|
* @returns An array of PIXI.Points
|
|
* @memberof GeoPolygon
|
|
*/
|
|
static _transformToPIXI(points = []) {
|
|
let polygon = [];
|
|
points.forEach(point => {
|
|
polygon.push(new PIXI.Point(point.x, point.y));
|
|
});
|
|
|
|
return polygon
|
|
}
|
|
|
|
/**
|
|
* Draws a single shape onto the graphics object.
|
|
* Useful when being called from subclass.
|
|
*
|
|
* @protected
|
|
* @param {Array.<PIXI.Point>} polygon - An array of PIXI.Points for drawing a polygon.
|
|
* @param {Array.<PIXI.Point>} [hole=[]] - An Array of PIXI.Points for cutting a hole into the polygon
|
|
* @memberof GeoShape
|
|
*/
|
|
_drawShape(polygon, hole = []) {
|
|
// We save the fill specified in the onDraw event handler.
|
|
//
|
|
// Consider: Maybe it would be a good idea to add a 'onHoleDraw'
|
|
// callback to make the hole customizable. Maybe you want
|
|
// to fill it with a different color or an mediocre alpha value.
|
|
// then feel free to implement it.
|
|
let { fill, alpha } = PIXIUtils.saveFill(this.graphics);
|
|
|
|
/**
|
|
* This may seem redundant, but it's required
|
|
* to make the hole clickable.
|
|
*
|
|
* It was a bit confusing, so I made a CodePen
|
|
* for it: https://codepen.io/Ukmasmu/pen/WJEaoK
|
|
*/
|
|
if (hole.length > 0) {
|
|
this.graphics.beginFill(0x0000ff, 0.0000001);
|
|
this.graphics.drawPolygon(hole);
|
|
}
|
|
|
|
this.graphics.beginFill(fill, alpha);
|
|
this.graphics.drawPolygon(polygon);
|
|
|
|
if (hole.length > 0) {
|
|
this.graphics.beginHole();
|
|
this.graphics.drawPolygon(hole);
|
|
this.graphics.endHole();
|
|
}
|
|
}
|
|
}
|
|
|
|
class GeoMultiShape extends GeoShape {
|
|
static _manipulatePoints(points, func) {
|
|
points.forEach(shape => {
|
|
GeoShape._manipulatePoints(shape, func);
|
|
});
|
|
}
|
|
|
|
static _getPointsFrom(multiShapeArray) {
|
|
let points = [];
|
|
multiShapeArray.forEach(shape => {
|
|
points = points.concat(GeoShape._getPointsFrom(shape));
|
|
});
|
|
return points
|
|
}
|
|
|
|
static _adaptPoint(coordinates, map) {
|
|
let points = [];
|
|
coordinates.forEach(shape => {
|
|
let adaptedPoint = GeoShape._adaptPoint(shape, map);
|
|
points.push(adaptedPoint);
|
|
});
|
|
return points
|
|
}
|
|
|
|
calculateLocation() {
|
|
let coms = [];
|
|
this.coordinates.forEach(polygon => {
|
|
coms.push(GeoGraphics.calculateCenterOfMass(polygon));
|
|
});
|
|
|
|
return GeoGraphics.calculateCenterOfMass(coms)
|
|
}
|
|
|
|
_drawFrom(multiShape) {
|
|
multiShape.forEach(shape => {
|
|
super._drawFrom(shape);
|
|
});
|
|
}
|
|
}
|
|
|
|
class PIXIUtils {
|
|
/*
|
|
* Transform a pixi text to it's actual screensize,
|
|
* ignoring it's local transforms
|
|
*/
|
|
static toScreenFontSize(pixiText, fontSize = null) {
|
|
pixiText._recursivePostUpdateTransform();
|
|
|
|
let normalizedScale = {
|
|
x: pixiText.scale.x / pixiText.transform.worldTransform.a,
|
|
y: pixiText.scale.x / pixiText.transform.worldTransform.d
|
|
};
|
|
|
|
pixiText.scale = { x: normalizedScale.x, y: normalizedScale.y };
|
|
if (fontSize) pixiText.style.fontSize = fontSize;
|
|
}
|
|
|
|
static saveFill(graphics) {
|
|
return {
|
|
fill: graphics.fill.color,
|
|
alpha: graphics.fill.alpha
|
|
}
|
|
}
|
|
}
|
|
|
|
class MapList {
|
|
constructor(active = null, maps = {}) {
|
|
this.maps = maps;
|
|
this.active = active;
|
|
|
|
if (Object.keys(maps).length > 0) this.select(active);
|
|
}
|
|
|
|
/**
|
|
* Selects a map from the map list.
|
|
*
|
|
* @param {string} active - Name of the map to select.
|
|
* @returns
|
|
* @memberof MapList
|
|
*/
|
|
select(active) {
|
|
let map = null;
|
|
|
|
if (active !== this.active) {
|
|
let keys = Object.keys(this.maps);
|
|
if (keys.length > 0) {
|
|
if (this.maps[active] == null) {
|
|
let altActive = keys[0];
|
|
console.warn(
|
|
`The MapList does not contain the provided active key '${active}'. Used '${altActive}' as fallback.`
|
|
);
|
|
|
|
active = altActive;
|
|
}
|
|
|
|
if (this.active !== active) {
|
|
this.active = active;
|
|
map = this.maps[active];
|
|
}
|
|
} else {
|
|
console.error(`Could not provide a fallback map! The map object is empty.`);
|
|
}
|
|
}
|
|
|
|
return map
|
|
}
|
|
|
|
clone() {
|
|
let maps = {};
|
|
|
|
for (let name of Object.keys(this.maps)) {
|
|
maps[name] = this.maps[name].clone();
|
|
}
|
|
|
|
return new MapList(this.active, maps)
|
|
}
|
|
|
|
add(key, map) {
|
|
if (this.maps[key] != null) consol.warn('Key already in mapList. The existing key was overwritten.');
|
|
if (this.active == null) this.active = key;
|
|
map.name = key;
|
|
this.maps[key] = map;
|
|
}
|
|
|
|
get map() {
|
|
return this.maps && this.maps[this.active] ? this.maps[this.active] : null
|
|
}
|
|
|
|
next() {
|
|
let keys = Object.keys(this.maps);
|
|
let idx = keys.indexOf(this.active);
|
|
|
|
let next = idx + 1 < keys.length ? keys[idx + 1] : keys[0];
|
|
return next
|
|
}
|
|
|
|
cleanup() {
|
|
for (let key in this.maps) {
|
|
let map = this.maps[key];
|
|
map.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
//import { GeoGraphics } from "../pixi/geographics.js"
|
|
|
|
/**
|
|
* The GeoLayer is a special PIXILayer, that recognizes other GeoLayers and
|
|
* GeoGraphics. The layer can be adapted to a map and notifies all Geo-Children
|
|
* of the Adaption.
|
|
*/
|
|
class GeoLayer {
|
|
constructor(displayObject, opts = {}) {
|
|
if (displayObject == null || !(displayObject instanceof PIXI.DisplayObject)) {
|
|
console.error(
|
|
`You need to provide a displayObject to make a ${this.constructor.name} out of it.`,
|
|
displayObject
|
|
);
|
|
return null
|
|
} else {
|
|
this.geographics = [];
|
|
// displayObject.map = this
|
|
this.displayObject = displayObject;
|
|
|
|
this.pixiAddChild = displayObject.addChild.bind(displayObject);
|
|
displayObject.addChild = (...elements) => {
|
|
elements.forEach(element => {
|
|
if (element instanceof GeoGraphics) {
|
|
element.setLayer(this);
|
|
this.geographics.push(element);
|
|
this.pixiAddChild(element.graphics);
|
|
} else {
|
|
this.pixiAddChild(element);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
this.name = opts['name'] != null ? opts.name : 'Unnamed Layer';
|
|
this.parent = null;
|
|
this.parentMapLayerTransformedHandler = new EventHandler('onParentMapLayerTransformed');
|
|
this.layers = [];
|
|
this._visibility = { min: 0, max: Number.MAX_VALUE };
|
|
}
|
|
|
|
parentMapLayerTransformed(mapLayer) {
|
|
this.layers.forEach(layer => {
|
|
if (!(layer instanceof MapList)) {
|
|
layer.parentMapLayerTransformed();
|
|
}
|
|
});
|
|
|
|
this.parentMapLayerTransformedHandler.call(null, mapLayer);
|
|
|
|
this.rescaleChildren();
|
|
}
|
|
|
|
rescaleChildren() {
|
|
let map = this.map;
|
|
|
|
if (this.rescale) {
|
|
if (map != null) {
|
|
let scale = map.image.scatter.scale;
|
|
|
|
this.displayObject.children.forEach(graphics => {
|
|
graphics.scale.set(1 / scale, 1 / scale);
|
|
});
|
|
}
|
|
}
|
|
|
|
let mapLayer = this.mapLayer;
|
|
if (this.visibility && mapLayer != null) {
|
|
const zoom = mapLayer.mapview.zoom;
|
|
|
|
// TODO
|
|
// Currently I dont know what elemnts was.
|
|
// We just log an error and resolve this on a later point.
|
|
if (zoom > this.visibility.min && zoom < this.visibility.max) {
|
|
this.displayObject.children.forEach(it => (it.visible = true));
|
|
} else {
|
|
this.displayObject.children.forEach(it => (it.visible = false));
|
|
}
|
|
}
|
|
}
|
|
|
|
set visibility(value) {
|
|
let { min = 0, max = Infinity } = value;
|
|
this._visibility = { min, max };
|
|
}
|
|
|
|
get visibility() {
|
|
return this._visibility
|
|
}
|
|
|
|
addChild(element) {
|
|
this.displayObject.addChild(element);
|
|
}
|
|
|
|
/**
|
|
* Adapts to a map. If the maplayer should adapt to the parent maplayer,
|
|
* no parameter must be specified.
|
|
*/
|
|
adapt(map = null) {
|
|
if (!map) map = this.map;
|
|
|
|
if (map) {
|
|
this.geographics.forEach(geographic => {
|
|
geographic.adaptTo(map);
|
|
});
|
|
|
|
this.layers.forEach(layer => {
|
|
if (layer.adapt) layer.adapt(map);
|
|
});
|
|
|
|
this.rescaleChildren();
|
|
} else console.error('There was no map specified.', this);
|
|
}
|
|
|
|
// place(geographic) {
|
|
// if (geographic.constructor.name.startsWith('Geo') && geographic.graphics) {
|
|
// // Fix to remove the rollupjs circular dependency
|
|
// //if (geographic instanceof GeoGraphics) {
|
|
// this.geographics.push(geographic)
|
|
// super.place(geographic.graphics)
|
|
// } else super.place(geographic)
|
|
// }
|
|
|
|
removeFromParent() {
|
|
if (this.parent) {
|
|
this.parent.removeLayer(this);
|
|
}
|
|
}
|
|
|
|
removeLayer(layer) {
|
|
let idx = this.layers.indexOf(layer);
|
|
if (idx != -1) {
|
|
layer.parent = null;
|
|
this.layers.splice(idx, 1);
|
|
|
|
if (layer.displayObject.parent) {
|
|
layer.displayObject.parent.removeChild(layer.displayObject);
|
|
}
|
|
} else console.warn('Tried to remove layer that was not set.', this, layer);
|
|
}
|
|
|
|
remove(graphics) {
|
|
if (graphics instanceof GeoGraphics) {
|
|
let index = this.geographics.indexOf(geographics);
|
|
if (index != -1) {
|
|
this.displayObject.removeChild(geographics);
|
|
} else {
|
|
console.error('Could not remove geographics from geolayer.', this, geographics);
|
|
}
|
|
} else {
|
|
this.displayObject.removeChild(graphics);
|
|
}
|
|
}
|
|
|
|
set parent(parent) {
|
|
this._parent = parent;
|
|
}
|
|
|
|
get parent() {
|
|
return this._parent
|
|
}
|
|
|
|
addLayer(layer) {
|
|
if (layer instanceof GeoLayer || layer instanceof MapLayer) {
|
|
layer.removeFromParent();
|
|
|
|
this.layers.push(layer);
|
|
layer.parent = this;
|
|
layer.parentChanged();
|
|
|
|
this.displayObject.addChild(layer.displayObject);
|
|
if (this.map) layer.geographics.forEach(geographics => geographics.adaptTo(this.map));
|
|
} else
|
|
console.error('Could not place layer. Only MapLayer and GeoLayers can be child layers of GeoLayers.', layer);
|
|
}
|
|
|
|
parentChanged() {
|
|
this.rescaleChildren();
|
|
}
|
|
|
|
//GeoLayers have to be children of a map layer,
|
|
// therefore we can recursively get the map.
|
|
get map() {
|
|
return this.mapLayer ? this.mapLayer.map : null
|
|
}
|
|
|
|
get mapLayer() {
|
|
return this._mapLayer ? this._mapLayer : this.parent.mapLayer
|
|
}
|
|
|
|
// clone(mapLayerClone) {
|
|
// const opts = {
|
|
// mapLayer: mapLayerClone,
|
|
// map: mapLayerClone.map
|
|
// }
|
|
// let geoLayerClone = new GeoLayer(opts)
|
|
|
|
// this.layers.forEach(layer => {
|
|
// let layerClone = layer.clone(opts)
|
|
// if (layerClone) {
|
|
// geoLayerClone.placeLayer(layerClone)
|
|
// }
|
|
// })
|
|
|
|
// this.geographics.forEach(geographics => {
|
|
// let clone = geographics.clone()
|
|
// if (clone) {
|
|
// geoLayerClone.place(clone)
|
|
// }
|
|
// })
|
|
|
|
// return geoLayerClone
|
|
// }
|
|
}
|
|
|
|
class MapLayer extends GeoLayer {
|
|
constructor(
|
|
mapList,
|
|
scatterContainer,
|
|
displayObject,
|
|
{ onTransform = null, onChange = null, focus = null, zoom = null, viewport = null, name = null } = {}
|
|
) {
|
|
super(displayObject, {
|
|
name
|
|
});
|
|
|
|
this.transformHandler = new EventHandler('onTransform', {
|
|
listeners: onTransform
|
|
});
|
|
|
|
this.scatterContainer = scatterContainer;
|
|
|
|
this.changeHandler = new EventHandler('onChange', {
|
|
listeners: onChange
|
|
});
|
|
|
|
this.mapview = new MapView({
|
|
zoom,
|
|
focus,
|
|
viewport
|
|
});
|
|
|
|
this.mapList = mapList;
|
|
|
|
// //TODO Implement error handling here.
|
|
// this.maps = maps
|
|
// if (opts.map) this.placeMap(opts.map)
|
|
this.dynamicElements = new Map();
|
|
|
|
// Binds the transformed callback beforehand.
|
|
this.transformed = this.transformed.bind(this);
|
|
|
|
this.changeMap(mapList.active);
|
|
}
|
|
|
|
adapt() {
|
|
this.layers.forEach(layer => {
|
|
if (layer.adapt) layer.adapt(this.map);
|
|
});
|
|
}
|
|
|
|
|
|
focus(coordinates, zoom) {
|
|
this.mapview.updateFocusPoint(this.map);
|
|
}
|
|
|
|
transformed(e) {
|
|
this.mapview.transformed(this.map);
|
|
this.layers.forEach(layer => layer.parentMapLayerTransformed(this));
|
|
this.transformHandler.call(this);
|
|
}
|
|
|
|
clone(scatterContainer, container = null) {
|
|
let mapList = this.mapList.clone();
|
|
container = container == null ? new PIXI.Container() : container;
|
|
|
|
let mapLayerClone = new MapLayer(mapList, scatterContainer, container, {
|
|
name: MapLayer.idx++,
|
|
viewport: this.mapview.viewport,
|
|
focus: this.mapview.focus,
|
|
zoom: this.mapview.zoom,
|
|
mapList
|
|
});
|
|
|
|
mapLayerClone.childrenVisibility = this.childrenVisibility;
|
|
return mapLayerClone
|
|
}
|
|
|
|
/**
|
|
* Helper function to quickly display the next map.
|
|
* Order is defined by the key ordering of the maplist.
|
|
*
|
|
* @memberof MapLayer
|
|
*/
|
|
next() {
|
|
let nextMap = this.mapList.next();
|
|
this.changeMap(nextMap);
|
|
}
|
|
|
|
/**
|
|
* Changes the map to the specified one, keeping the position and the zoom of the old map.
|
|
*
|
|
* @public
|
|
* @param {GeoMap} map
|
|
* @memberof MapLayer
|
|
*/
|
|
changeMap(
|
|
name
|
|
/* map ,
|
|
useScatterAsContainer = true // If set to false, the normal container is used. This is necessary when using submaps and the container need to be a RigidContainer.*/
|
|
) {
|
|
console.log('🗺️ Change map to: ', name);
|
|
let oldMap = this.map;
|
|
|
|
this.mapList.select(name);
|
|
|
|
if (oldMap) {
|
|
oldMap.unload();
|
|
oldMap.onTransform.remove(this.transformed);
|
|
}
|
|
|
|
let map = this.map;
|
|
if (map) {
|
|
map.load();
|
|
|
|
this.scatterContainer.addChild(map.image);
|
|
|
|
this.mapview.apply(map);
|
|
map.image.addChild(this.displayObject);
|
|
|
|
// A geolayer's displayObject is on the parent layer.
|
|
// A maplayer's displayobject is always the child of the map.
|
|
this.adapt();
|
|
|
|
this.changeHandler.call(this, map, oldMap);
|
|
|
|
//Call transform one time manually.
|
|
this.transformed();
|
|
map.onTransform.add(this.transformed);
|
|
} else {
|
|
console.error(`Could not change map to ${name}.`);
|
|
}
|
|
|
|
/*Logging.log(`Map change: ${key}`)
|
|
|
|
if (this.active !== key) {
|
|
if (this.maps.hasOwnProperty(key)) {
|
|
let old = this.map ? this.map : null
|
|
this._map = this.maps[key]
|
|
this._map.name = key
|
|
this.active = key
|
|
|
|
let container = useScatterAsContainer ? this.scatterContainer : this.container
|
|
|
|
this.map.load(container)
|
|
|
|
// Copies all layers.
|
|
this.layers.forEach(layer => {
|
|
if (old) this.map.image.addChild(layer.container)
|
|
})
|
|
|
|
this.placeMap(this.map)
|
|
|
|
/**
|
|
* TODO: Improve
|
|
*
|
|
* I'm quite sure if I made a design mistake here.
|
|
* In an earlier version I did not need to migrate the
|
|
* layers manually from the map to the next map.
|
|
*
|
|
* I believe the old version was a container next to the
|
|
* map, which got updated on transform.
|
|
*
|
|
* -SO
|
|
*/
|
|
/*
|
|
if (old) old.unload()
|
|
|
|
|
|
} else {
|
|
let keys = Object.keys(this.maps)
|
|
|
|
if (keys.length == 0) console.error('There is no map set for the map layer!')
|
|
else {
|
|
let fallbackMap = keys[0]
|
|
console.error(
|
|
`A map with the key (${key}) does not exists within the mapapp. Fallback to map: ${fallbackMap}.`
|
|
)
|
|
this.changeMap(fallbackMap, {
|
|
useScatterAsContainer
|
|
})
|
|
}
|
|
}
|
|
}*/
|
|
}
|
|
|
|
refocus() {
|
|
this.mapview.apply(this.map);
|
|
}
|
|
|
|
get map() {
|
|
return this.mapList.map
|
|
}
|
|
|
|
/**
|
|
* This is required for the consistency of georelated layers.
|
|
* The request traverses up to the mapLayer where it then returns
|
|
* the responsible map layer.
|
|
*/
|
|
get mapLayer() {
|
|
return this
|
|
}
|
|
|
|
cleanup() {
|
|
this.mapList.cleanup();
|
|
}
|
|
}
|
|
|
|
MapLayer.idx = 0;
|
|
|
|
/**
|
|
* MapApp is responsible for showing fullscreen
|
|
* map applications.
|
|
*
|
|
*/
|
|
class MapApp extends PIXIApp {
|
|
constructor(opts = {}) {
|
|
super(opts);
|
|
|
|
// Default values.
|
|
opts = Object.assign(
|
|
{
|
|
debug: false,
|
|
maps: {}, //required
|
|
view: null, //required
|
|
wrapper: null, // Required if you want to use DOM Overlays.
|
|
startmap: null,
|
|
coordsLogging: false,
|
|
overlays: {},
|
|
keycodes: {},
|
|
showHotkeys: false,
|
|
imageMapZoomHeight: 256, //Defines the zoomvalue 1 for all image maps inside the mapapp.
|
|
focus: null,
|
|
zoom: 1,
|
|
onMapChanged: null,
|
|
onSizeChanged: null,
|
|
onTransform: null
|
|
},
|
|
opts,
|
|
{
|
|
alpha: 0.5
|
|
}
|
|
);
|
|
this.submaps = [];
|
|
this.overlayElements = new Map();
|
|
this.debug = opts.debug;
|
|
this.fpsLogging = opts.fpsLogging;
|
|
this.showHotkeys = opts.showHotkeys;
|
|
this.keycodes = this._extractKeyCodes(opts.keycodes);
|
|
this.coordsLogging = opts.coordsLogging;
|
|
this.overlays = opts.overlays;
|
|
this.focus = opts.focus;
|
|
this.zoom = opts.zoom;
|
|
this.wrapper = opts.wrapper;
|
|
|
|
this.onMapChanged = new EventHandler('mapChanged', {
|
|
listeners: opts.onMapChanged
|
|
});
|
|
this.onSizeChanged = new EventHandler('sizeChanged', {
|
|
listeners: opts.onSizeChanged
|
|
});
|
|
this.onTransform = new EventHandler('transformed', {
|
|
listeners: opts.onTransform
|
|
});
|
|
|
|
/**
|
|
* When in debug mode, this allows the user to copy the center coordinates to the clipboard.
|
|
*/
|
|
if (this.debug) {
|
|
this.DRAW_MODES = {
|
|
PIXI_POINT: 0,
|
|
PATH_MODE: 1,
|
|
POLYGON_MODE: 2,
|
|
|
|
getName: function(num) {
|
|
let result = null;
|
|
for (const [key, val] of Object.entries(this)) {
|
|
if (val == num) result = key;
|
|
}
|
|
return result
|
|
}
|
|
};
|
|
this.drawMode = this.DRAW_MODES.PIXI_POINT;
|
|
this.drawData = [];
|
|
}
|
|
|
|
this.mapList = new MapList(opts.startmap ? opts.startmap : null, opts.maps ? opts.maps : {});
|
|
|
|
this._setupKeyboardUtils();
|
|
}
|
|
|
|
/**
|
|
* Log boundaries for a shown map.
|
|
* This is for development purposes only, if you want
|
|
* to find the boundaries of a shown map.
|
|
*
|
|
* @memberof MapApp
|
|
*/
|
|
logMapBoundaries() {
|
|
let map = this.mapLayer.map;
|
|
|
|
let boundaries = {
|
|
min: this.mapLayer.mapview.coordinatesFromWindowPoint(map, { x: 0, y: 0 }),
|
|
max: this.mapLayer.mapview.coordinatesFromWindowPoint(map, { x: 0, y: 0 })
|
|
};
|
|
|
|
console.log(JSON.stringify(boundaries));
|
|
}
|
|
|
|
_setupMapLayer() {
|
|
this.mapContainer = new PIXI.Container();
|
|
this.mapLayer = new MapLayer(this.mapList, this.scene, this.mapContainer, {
|
|
name: 'Root Map Layer',
|
|
focus: this.focus,
|
|
zoom: this.zoom,
|
|
onChange: this._mapChanged.bind(this)
|
|
});
|
|
}
|
|
|
|
setup() {
|
|
super.setup();
|
|
|
|
// TODO get access to fps display
|
|
let fpsDisplay = null;
|
|
this.stage.children.forEach(element => {
|
|
if (element.refreshFps) fpsDisplay = element;
|
|
});
|
|
|
|
this._setupMapLayer();
|
|
|
|
this.ui = new PIXI.Container();
|
|
this.scene.addChild(this.ui);
|
|
if (this.fpsLogging != null && fpsDisplay != null) this.ui.addChild(fpsDisplay);
|
|
|
|
if (this.coordsLogging) {
|
|
this.coordsDisplay = new CoordinateDisplay(this);
|
|
this.ui.addChild(this.coordsDisplay);
|
|
}
|
|
|
|
this.__dragging = false;
|
|
this.__events = new Map();
|
|
|
|
const scene = this.scene;
|
|
scene.interactive = true;
|
|
scene.on('pointercancel', this.__onEnd.bind(this));
|
|
scene.on('pointerdown', this.__onStart.bind(this));
|
|
scene.on('pointermove', this.__onMove.bind(this));
|
|
scene.on('pointerout', this.__onEnd.bind(this));
|
|
scene.on('pointerup', this.__onEnd.bind(this));
|
|
scene.on('pointerupoutside', this.__onEnd.bind(this));
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* You can add Dom elements directly to the app's DomLayer by calling
|
|
* appendChild(). The elements will be forced to use an absolute positioning.
|
|
* This shall prevent unwanted sideeffects with the app.
|
|
*
|
|
* @param {HTMLElement} element - Element to add to DOMRoot.
|
|
* @memberof MapApp
|
|
*/
|
|
appendChild(element) {
|
|
if (!this.wrapper)
|
|
console.error(
|
|
`When using DOMElements on ${this.constructor.name} you must specify a wrapper in the constructor.`
|
|
);
|
|
if (!(element instanceof HTMLElement))
|
|
console.error(
|
|
'You can only append HTMLElements to DOM. If you want to add PIXI elements to the scene call app.scene.addChild(element).'
|
|
);
|
|
else {
|
|
Object.assign(element.style, { position: 'absolute' });
|
|
this.wrapper.appendChild(element);
|
|
}
|
|
}
|
|
|
|
layout(width, height) {
|
|
this.scene.resize(width, height);
|
|
this.mapLayer.mapview.update();
|
|
this.onSizeChanged.call(this);
|
|
}
|
|
|
|
sceneFactory() {
|
|
return new RigidScatterContainer(this.width, this.height, this.renderer, {
|
|
app: this,
|
|
showBounds: true,
|
|
showTouches: true,
|
|
showPolygon: true,
|
|
container: this
|
|
})
|
|
}
|
|
|
|
selectMap(key) {
|
|
let result = this.mapList.select(key);
|
|
|
|
console.log('Select map', key, result);
|
|
if (result && this.mapLayer) {
|
|
this.mapLayer.changeMap(this.mapList.map);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds and sets a map to the mapapp.
|
|
*
|
|
* @param {string} key - Name of the map.
|
|
* @param {GeoMap} map - Map to add.
|
|
* @memberof MapApp
|
|
*/
|
|
setMap(key, map) {
|
|
this.addMap(key, map);
|
|
this.selectMap(key);
|
|
}
|
|
|
|
addMap(key, map) {
|
|
if (this.mapList) this.mapList.add(key, map);
|
|
else console.error('Cannot access mapLayer. It was not initialized yet.');
|
|
}
|
|
|
|
addMaps(mapObject) {
|
|
for (let [key, val] of Object.entries(mapObject)) {
|
|
this.addMap(key, val);
|
|
}
|
|
}
|
|
|
|
transformed(event) {
|
|
this.onTransform.call(this, event);
|
|
}
|
|
|
|
_mapChanged(lastMap) {
|
|
if (lastMap) {
|
|
lastMap.flushHandlers();
|
|
}
|
|
|
|
this.transformed();
|
|
this.onMapChanged.call(this, this.map);
|
|
|
|
this.map.onTransform.add(this.transformed.bind(this));
|
|
|
|
if (this.ui && this.ui.parent) {
|
|
const parent = this.ui.parent;
|
|
parent.removeChild(this.ui);
|
|
parent.addChild(this.ui);
|
|
}
|
|
}
|
|
|
|
_doesOverlayElementExist(layer, type, name) {
|
|
let layerElements = this.overlayElements.get(layer);
|
|
return layerElements != undefined && layerElements[type] != null && layerElements[type][name] != null
|
|
}
|
|
|
|
_getOverlayElement(layer, type, name) {
|
|
let layerElements = this.overlayElements.get(layer);
|
|
return layerElements[type][name]
|
|
}
|
|
|
|
_setOverlayElement(layer, type, name, value) {
|
|
let obj = this.overlayElements.get(layer);
|
|
if (obj == undefined) obj = {};
|
|
|
|
if (obj[type] == null) obj[type] = {};
|
|
|
|
obj[type][name] = value;
|
|
|
|
this.overlayElements.set(layer, obj);
|
|
}
|
|
|
|
/**
|
|
* Copies the current coordinates to the clipboard.
|
|
*/
|
|
locationToClipboard() {
|
|
let hidden = document.createElement('input');
|
|
document.body.appendChild(hidden);
|
|
hidden.value = '"location":' + JSON.stringify(app.mapLayer.mapview.focus);
|
|
hidden.readOnly = true;
|
|
hidden.select();
|
|
document.execCommand('Copy');
|
|
this.showNotification('Copied location to clipboard.');
|
|
document.body.removeChild(hidden);
|
|
}
|
|
|
|
pathToClipboard() {
|
|
let hidden = document.createElement('input');
|
|
document.body.appendChild(hidden);
|
|
|
|
this.drawData.push(this.mapLayer.mapview.focus);
|
|
|
|
if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) {
|
|
let data = {
|
|
type: 'Polygon',
|
|
coordinates: [this.drawData]
|
|
};
|
|
|
|
hidden.value = '"geometry":' + JSON.stringify(data);
|
|
} else {
|
|
// PATH_MODE is default.
|
|
hidden.value = JSON.stringify(this.drawData);
|
|
}
|
|
hidden.select();
|
|
document.execCommand('Copy');
|
|
this.showNotification('Location appended in clipboard.');
|
|
document.body.removeChild(hidden);
|
|
}
|
|
|
|
get map() {
|
|
return this.mapList.map
|
|
}
|
|
|
|
get activeMapKey() {
|
|
return this.mapLayer.active
|
|
}
|
|
|
|
getRelativePosition(x, y) {
|
|
return {
|
|
x: x * app.width,
|
|
y: y * app.height
|
|
}
|
|
}
|
|
|
|
clearDrawData() {
|
|
this.drawData = [];
|
|
}
|
|
|
|
showNotification(msg) {
|
|
let notification = document.createElement('div');
|
|
notification.classList.add('notification');
|
|
|
|
let text = document.createElement('p');
|
|
text.innerHTML = msg;
|
|
notification.appendChild(text);
|
|
|
|
/**
|
|
* TODO: move styling (except opacity) to css.
|
|
*/
|
|
Object.assign(notification.style, {
|
|
opacity: 0,
|
|
top: 0,
|
|
left: 0,
|
|
position: 'fixed',
|
|
display: 'flex',
|
|
width: '100%',
|
|
height: '100%',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
pointerEvents: 'none'
|
|
});
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
let popAnimation = new TimelineLite({
|
|
onComplete: () => {
|
|
notification.parentNode.removeChild(notification);
|
|
}
|
|
});
|
|
let animationSpeed = 0.5;
|
|
|
|
popAnimation
|
|
.to(notification, animationSpeed, {
|
|
opacity: 1
|
|
})
|
|
.to(notification, animationSpeed, {
|
|
opacity: 0
|
|
});
|
|
}
|
|
|
|
_currentLocationToString() {}
|
|
|
|
_matchKeyCode(event, keyCode) {
|
|
// If keycode does not exist or is invalid - return.
|
|
if (!keyCode || keyCode.key == null) return false
|
|
|
|
let code = keyCode.key;
|
|
if (event.shiftKey) code -= 32;
|
|
const key = event.which == code || event.keyCode == code || event.charCode == code;
|
|
|
|
const ctrl = keyCode.ctrlKey == event.ctrlKey;
|
|
const shift = keyCode.shiftKey == event.shiftKey;
|
|
const alt = keyCode.altKey == event.altKey;
|
|
|
|
return key && ctrl && shift && alt
|
|
}
|
|
|
|
_setupKeyboardUtils() {
|
|
document.body.addEventListener('keypress', event => {
|
|
this._checkForKeyCode(event);
|
|
});
|
|
}
|
|
|
|
_checkForKeyCode(event) {
|
|
if (this._matchKeyCode(event, this.keycodes.copyCoordinate)) {
|
|
event.preventDefault();
|
|
|
|
switch (this.drawMode) {
|
|
case this.DRAW_MODES.PIXI_POINT:
|
|
this.locationToClipboard();
|
|
break
|
|
case this.DRAW_MODES.PATH_MODE:
|
|
case this.DRAW_MODES.POLYGON_MODE:
|
|
this.pathToClipboard();
|
|
break
|
|
default:
|
|
console.error(
|
|
`Draw mode is not implemented yet: ${this.DRAW_MODES.getName(this.drawMode)}(${this.drawMode}).`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (this._matchKeyCode(event, this.keycodes.togglePathMode)) {
|
|
if (this.drawMode == this.DRAW_MODES.PATH_MODE) {
|
|
this.showNotification('Path Mode disabled.');
|
|
this._resetDrawMode();
|
|
} else {
|
|
this.drawMode = this.DRAW_MODES.PATH_MODE;
|
|
this.showNotification('Path Mode enabled.');
|
|
this.clearDrawData();
|
|
}
|
|
}
|
|
|
|
//When SHIFT+P is pressed POLYGON-MODE is toggled:
|
|
if (this._matchKeyCode(event, this.keycodes.togglePolygonMode)) {
|
|
if (this.drawMode == this.DRAW_MODES.POLYGON_MODE) {
|
|
this.showNotification('Polygon Mode disabled.');
|
|
this._resetDrawMode();
|
|
} else {
|
|
this.drawMode = this.DRAW_MODES.POLYGON_MODE;
|
|
this.showNotification('Polygon Mode enabled.');
|
|
this.clearDrawData();
|
|
}
|
|
}
|
|
|
|
// When SHIFT+X is pressed toggle crosshair
|
|
if (this.keycodes.toggleUi && this._matchKeyCode(event, this.keycodes.toggleUi)) {
|
|
if (this.pixiUiLayer.visible) {
|
|
this.pixiUiLayer.hide();
|
|
this.domUiLayer.hide();
|
|
} else {
|
|
this.pixiUiLayer.show();
|
|
this.domUiLayer.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
_resetDrawMode() {
|
|
this.drawMode = this.DRAW_MODES.PIXI_POINT;
|
|
}
|
|
|
|
_extractKeyCodes(keycodeText) {
|
|
let out = {};
|
|
|
|
for (let [name, combinationString] of Object.entries(keycodeText)) {
|
|
let keys = combinationString.split('+');
|
|
out[name] = {
|
|
key: null,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false
|
|
};
|
|
let errors = [];
|
|
|
|
const special = ['shift', 'ctrl', 'alt'];
|
|
keys.forEach(key => {
|
|
if (key.length == 1) {
|
|
if (out[name].key) {
|
|
const error = 'Tried to set multiple keys as keycode. This is currently not supported.';
|
|
errors.push(error);
|
|
} else out[name].key = key.charCodeAt(0);
|
|
} else {
|
|
key = key.toLowerCase();
|
|
if (special.indexOf(key) != -1) {
|
|
out[name][key + 'Key'] = true;
|
|
} else {
|
|
const error = 'Wrong / non-implemented special character OR typo in key: ' + key;
|
|
errors.push(error);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Disable hotkey if an error occured.
|
|
if (errors.length > 0)
|
|
out[name] = {
|
|
key: null,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false
|
|
};
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
__onStart(event) {
|
|
this.__dragging = true;
|
|
|
|
let hittedSubmap = null;
|
|
let center = null;
|
|
for (const submap of this.submaps) {
|
|
const radius = submap.container.width / 2;
|
|
const distance = Points.distance(submap.center, event.data.global);
|
|
if (distance < radius) {
|
|
hittedSubmap = submap;
|
|
center = submap.center;
|
|
}
|
|
}
|
|
|
|
this.__events.set(event.data.identifier, {
|
|
event,
|
|
submap: hittedSubmap,
|
|
center
|
|
});
|
|
}
|
|
|
|
__onMove(event) {
|
|
if (this.__dragging) {
|
|
const myevent = this.__events.get(event.data.identifier);
|
|
|
|
if (myevent && myevent.submap) {
|
|
const submap = myevent.submap;
|
|
const center = submap.center;
|
|
const radius = submap.container.width / 2;
|
|
const distance = Points.distance(center, event.data.global) / submap.scatter.scale;
|
|
}
|
|
|
|
// for (const submap of this.submaps) {
|
|
// const center = submap.center
|
|
// const radius = submap.container.width / 2
|
|
// const distance = Points.distance(center, event.data.global) / submap.scatter.scale
|
|
// const inside = distance < radius + 10
|
|
|
|
// console.log(distance, radius)
|
|
|
|
// if (inside) {
|
|
// // (this.width + 80) / 2 * this.scatter.scale
|
|
// //const width = (submap.width + 80) / 2 * submap.scatter.scale
|
|
// //console.log(width)
|
|
|
|
// if (distance > radius) {
|
|
// submap.resize((distance) * 2, .2)
|
|
// }
|
|
// } else {
|
|
// if (distance < radius + 20) {
|
|
// //submap.resize((distance - 30) * 2, .2)
|
|
// }
|
|
// }
|
|
// }
|
|
}
|
|
}
|
|
|
|
__onEnd(event) {
|
|
this.__dragging = false;
|
|
this.__events.delete(event.data.identifier);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper class for handling GeoJson data.
|
|
* As specified by [RFC7946](https://tools.ietf.org/html/rfc7946).
|
|
*
|
|
* @static
|
|
* @export
|
|
* @class GeoJsonGraphics
|
|
* @extends {GeoGraphics}
|
|
*/
|
|
class GeoJson {
|
|
static isLineType(type) {
|
|
return type == 'LineString' || type == 'MultiLineString'
|
|
}
|
|
|
|
static _getFormatStringOfType(type) {
|
|
let description = ', where p represents a coordinate point';
|
|
let format = '';
|
|
switch (type) {
|
|
case 'Point':
|
|
format = 'p';
|
|
break
|
|
case 'LineString':
|
|
format = '[p1,p2,p3,...,pn]';
|
|
break
|
|
case 'Polygon':
|
|
format = '[ [p1,p2,...,pn], [h1,h2,...,hn] ]';
|
|
description += ' and h also represents a coordinate point, but it creates a hole.';
|
|
break
|
|
default:
|
|
format = type;
|
|
description = " is either not valid or not yet implemented in method '_getFormatStringOfType(type)'.";
|
|
}
|
|
|
|
return format + ' - ' + description
|
|
}
|
|
|
|
static get types() {
|
|
return ['Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon']
|
|
}
|
|
|
|
static unwrapFeatureCollection(featureCollection) {
|
|
if (featureCollection.features == null) {
|
|
console.error(
|
|
'Error at GeoJson.unrwapFeatureCollection(collection): Provided object was no valid FeatureCollection.',
|
|
featureCollection
|
|
);
|
|
return
|
|
}
|
|
|
|
let list = [];
|
|
|
|
featureCollection.features.forEach(feature => {
|
|
let { type, coordinates } = feature;
|
|
|
|
if (feature.type.toLowerCase() == 'feature') {
|
|
({ type, coordinates } = feature.geometry);
|
|
}
|
|
|
|
list.push({ type, coordinates });
|
|
// console.log({type, coordinates})
|
|
});
|
|
|
|
return list
|
|
}
|
|
|
|
static validateAndConvert(type, coordinates) {
|
|
if (!GeoJson.validateType(type)) throw new GeoJson.InvalidTypeError(type)
|
|
else {
|
|
if (GeoJson.validateCoordinates(type, coordinates)) {
|
|
let converted = GeoJson.convert(type, coordinates);
|
|
return converted
|
|
} else {
|
|
console.error(
|
|
`Coordinates are invalid. They must be in format of type '${type} - ${GeoJson._getFormatStringOfType(
|
|
type
|
|
)}'`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
static validateType(type) {
|
|
return GeoJson.types.indexOf(type) != -1
|
|
}
|
|
|
|
static validateCoordinates(type, coordinates) {
|
|
let valid = false;
|
|
|
|
switch (type) {
|
|
case 'Point':
|
|
valid = !!GeoJson.validateAndConvertPoint(coordinates);
|
|
break
|
|
case 'LineString':
|
|
valid = GeoJson.validateLineString(coordinates);
|
|
break
|
|
case 'Polygon':
|
|
valid = GeoJson.validatePolygon(coordinates);
|
|
break
|
|
case 'MultiPolygon':
|
|
valid = true;
|
|
for (let i = 0; i < coordinates.length; i++) {
|
|
if (!GeoJson.validatePolygon(coordinates[i])) {
|
|
valid = false;
|
|
break
|
|
}
|
|
}
|
|
break
|
|
case 'MultiPoint':
|
|
case 'MultiLineString':
|
|
default:
|
|
console.error('Type was not yet implemented: ', type);
|
|
}
|
|
return valid
|
|
}
|
|
|
|
/**
|
|
* Validates a point if it's an valid coordinate.
|
|
*
|
|
* NOTE: Here we are not following the GeoJSON standard.
|
|
* For convenience multiple forms of representing a coordinate are
|
|
* considered valid. A complete list is provided in the GeoUtils.
|
|
*
|
|
* @param {object} point - The point that is tested for validity.
|
|
* @returns
|
|
* @memberof GeoJson
|
|
*/
|
|
static validateAndConvertPoint(point) {
|
|
return GeoUtils.validateCoordinate(point)
|
|
}
|
|
|
|
/**
|
|
*Validates if the given points represent a 'LineString'.
|
|
*
|
|
* @param {array} points - A list of coordinates that represent a line.
|
|
* @returns {boolean} - Returns true, if the array is in formes as: [x1,x2,x3,...,xn]. Where x# represent a valid coordinate.
|
|
* @memberof GeoJson
|
|
*/
|
|
static validateLineString(points) {
|
|
let valid = false;
|
|
if (Array.isArray(points)) valid = points.every(GeoJson.validateAndConvertPoint);
|
|
return valid
|
|
}
|
|
|
|
static validatePolygon(points) {
|
|
let valid = false;
|
|
if ((Array.isArray(points) && points.length >= 1) || points.length <= 2)
|
|
valid = points.every(this.validateLineString);
|
|
return valid
|
|
}
|
|
|
|
static convert(type, coordinates) {
|
|
let converted = null;
|
|
switch (type) {
|
|
case 'Point':
|
|
converted = GeoJson.validateAndConvertPoint(coordinates);
|
|
break
|
|
case 'LineString':
|
|
converted = GeoJson._convertLineString(coordinates);
|
|
break
|
|
case 'Polygon':
|
|
converted = GeoJson._convertPolygon(coordinates);
|
|
break
|
|
case 'MultiPolygon':
|
|
converted = GeoJson._convertMultiPolygon(coordinates);
|
|
break
|
|
default:
|
|
throw new GeoJson.InvalidTypeError(type)
|
|
}
|
|
|
|
return converted
|
|
}
|
|
|
|
static _convertLineString(coordinates) {
|
|
return coordinates.map(point => {
|
|
return GeoJson.validateAndConvertPoint(point)
|
|
})
|
|
}
|
|
|
|
static _convertPolygon(coordinates) {
|
|
return coordinates.map(shape => {
|
|
return GeoJson._convertLineString(shape)
|
|
})
|
|
}
|
|
static _convertMultiPolygon(coordinates) {
|
|
return coordinates.map(polygon => {
|
|
return GeoJson._convertPolygon(polygon)
|
|
})
|
|
}
|
|
}
|
|
|
|
GeoJson.InvalidTypeError = class extends Error {
|
|
constructor(type) {
|
|
super(`The requested Type was not implemented: ${type}.`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* GeoUtils contains a collection of useful functions when working with maps.
|
|
*
|
|
* @static
|
|
*/
|
|
class GeoUtils {
|
|
static transformToGeoGraphics(list) {
|
|
let geographicsList = [];
|
|
list.forEach(item => {
|
|
if (item.type == 'FeatureCollection') {
|
|
item.features.forEach(feature => {
|
|
let { type, coordinates } = feature;
|
|
|
|
if (type.toLowerCase() == 'feature') ({ type, coordinates } = feature.geometry);
|
|
|
|
coordinates = GeoJson.validateAndConvert(type, coordinates);
|
|
|
|
let geographics = this.fromGeoJsonToGeoGraphics(type, coordinates);
|
|
if (geographics) geographicsList.push(geographics);
|
|
});
|
|
} else {
|
|
let geo = fromGeoJsonToGeoGraphics(item.type, item.geometry);
|
|
if (geo) geographicsList.push(geo);
|
|
}
|
|
});
|
|
return geographicsList
|
|
}
|
|
|
|
static resolveFeatureCollection(collection) {
|
|
if (!collection.features) {
|
|
console.error(
|
|
'Error in GeoUtils.resolveFeatureCollection(colelction): Passed parameter was no feature collection.',
|
|
collection
|
|
);
|
|
return
|
|
}
|
|
let geojson = [];
|
|
collection.features.forEach(feature => {
|
|
let { type, coordinates } = feature;
|
|
|
|
if (feature.type == feature) {
|
|
coordinates = feature.geometry.coordinates;
|
|
type = feature.geometry.type;
|
|
}
|
|
|
|
geojson.push({ type, coordinates });
|
|
});
|
|
|
|
return geojson
|
|
}
|
|
|
|
/**
|
|
* Creates the appropriate GeoGraphics object from a GeoJson type.
|
|
* The coordinates need to be alread in the appropriate PIXI format.
|
|
* If not already - this can be achieved by calling 'GeoJson.validateAndConvert(type, points)'
|
|
* beforehand.
|
|
*
|
|
* @static
|
|
* @param {string} type - Any of the GeoJson types ('Point', 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', 'MultiPolygon').
|
|
* @param {array} coordinates - Array of coordinates that fit the type. The positions within these must be in PIXI format: {x:a, y:b}.
|
|
* @returns {GeoGraphics} - Returns a GeoGraphics object. If the conversion fails, it returns null.
|
|
* @memberof GeoGraphics
|
|
*/
|
|
static fromGeoJsonToGeoGraphics(type, coordinates, opts = {}) {
|
|
let geographics = null;
|
|
|
|
/**
|
|
* TODO: REMOVE
|
|
* Just for initial debugging purposes
|
|
*/
|
|
Object.assign(opts, {
|
|
debug: true
|
|
});
|
|
|
|
switch (type) {
|
|
case 'Polygon':
|
|
geographics = new GeoShape(coordinates, opts);
|
|
break
|
|
|
|
case 'MultiPolygon':
|
|
geographics = new GeoMultiShape(coordinates, opts);
|
|
break
|
|
case 'LineString':
|
|
geographics = new GeoLine(coordinates, opts);
|
|
break
|
|
case 'Point':
|
|
geographics = new GeoPoint(coordinates, opts);
|
|
break
|
|
default:
|
|
console.log('Could not create Geographics for type: ' + type + '. This was not implemented yet.');
|
|
//Nothing
|
|
}
|
|
|
|
return geographics
|
|
}
|
|
|
|
/**
|
|
* Validates an object, if it's an coordinate object.
|
|
* Coordinate objects can be in the following forms:
|
|
*
|
|
* [lng, lat] - GeoJSON array format. !Attention lng and lat are swapped compared to the x,y format.
|
|
* {x: lat, y: lng} - 'correct' PIXI.format
|
|
* {lat, lng}
|
|
* {latitude: lat, longitude: lng}
|
|
*
|
|
* @static
|
|
* @param {object / array} coordinate - Coordinate to be tested, if it is an valid coordinate.
|
|
* @returns - Returns the coordinate properly transformed. If transformation was not possible, it returns null.
|
|
* @memberof GeoGraphics
|
|
*/
|
|
static validateCoordinate(coordinate) {
|
|
if (Array.isArray(coordinate)) {
|
|
if (coordinate.length == 2 && typeof coordinate[0] == 'number' && typeof coordinate[1] == 'number')
|
|
return new PIXI.Point(coordinate[1], coordinate[0])
|
|
else return false
|
|
} else {
|
|
const latvalues = ['x', 'lat', 'latitude'];
|
|
const lngvalues = ['y', 'lng', 'longitude'];
|
|
|
|
let result = {};
|
|
for (let key of Object.keys(coordinate)) {
|
|
let target = key.toLowerCase();
|
|
if (latvalues.indexOf(target) !== -1) result.x = coordinate[key];
|
|
else if (lngvalues.indexOf(target) !== -1) result.y = coordinate[key];
|
|
}
|
|
|
|
if (result.hasOwnProperty('x') && result.hasOwnProperty('y')) return new PIXI.Point(result.x, result.y)
|
|
else return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Overlay-Class shall provide and easy to use frameworkd
|
|
* of marking points of interest on a map, using shapes, geometries,
|
|
* items or text.
|
|
*
|
|
* It's highly configurable and adaptable to a variety of scenarios.
|
|
*
|
|
*/
|
|
|
|
class Overlay {
|
|
/**
|
|
*Creates an instance of Overlay.
|
|
* @param {*} [{
|
|
* name = "unnamed", {string}
|
|
* connector = false,
|
|
* connectorType = "line",
|
|
* location = null,
|
|
* label = false,
|
|
* labelType = null,
|
|
* labelLocation = null,
|
|
* labelAlignment = "center",
|
|
* labelVerticalAlign = "underneath",
|
|
* labelTarget = null,
|
|
* shape = "geometry", {string} - Defines the shape to be used: geometry, circle, rectangle
|
|
* geometry = null,
|
|
* geometryType = "point",
|
|
* geometryFallback = "circle",
|
|
* informationPopup = "infocard",
|
|
*
|
|
* }={}]
|
|
* @memberof Overlay
|
|
*/
|
|
constructor(opts = {}) {
|
|
let defaultTextStyle = new PIXI.TextStyle();
|
|
|
|
Object.assign(
|
|
this,
|
|
{
|
|
type: 'auto',
|
|
name: 'unnamed',
|
|
|
|
borderColor: 0x000000,
|
|
borderThickness: 5,
|
|
|
|
disabledAlpha: 1,
|
|
disabledColor: 0xaa1111,
|
|
disabledBorderColor: 0x000000,
|
|
enabled: true,
|
|
labelScale: 1,
|
|
location: null,
|
|
label: false,
|
|
labelType: null,
|
|
labelLocation: null,
|
|
labelAlignment: 'center',
|
|
labelVerticalAlign: 'underneath',
|
|
labelSpacing: 10,
|
|
labelTarget: null,
|
|
geometry: null,
|
|
information: '',
|
|
informationPath: null,
|
|
informationType: 'popup',
|
|
icon: null,
|
|
iconAlpha: 1,
|
|
iconAnchor: { x: 0.5, y: 0.5 },
|
|
iconColor: 0xffffff,
|
|
iconScale: 1,
|
|
iconOffset: { x: 0, y: 0 },
|
|
items: [],
|
|
color: 0xff00ff,
|
|
fillAlpha: 1,
|
|
size: 50,
|
|
scale: 1,
|
|
rescale: false,
|
|
popoverOffset: { x: 0, y: 0 },
|
|
zoomVisibility: { min: 0, max: Number.MAX_VALUE },
|
|
|
|
/**
|
|
* The following Attributes are taken from the TextStyle class
|
|
* of PIXI. All can be overwritten in the overlay's JSON file.
|
|
*/
|
|
labelMultiLineAlign: defaultTextStyle.align,
|
|
labelBreakWords: defaultTextStyle.breakWords,
|
|
labelDropShadow: defaultTextStyle.dropShadow,
|
|
labelDropShadowAlpha: defaultTextStyle.dropShadowAlpha,
|
|
labelDropShadowAngle: defaultTextStyle.dropShadowAngle,
|
|
labelDropShadowBlur: defaultTextStyle.dropShadowBlur,
|
|
labelDropShadowColor: defaultTextStyle.dropShadowColor,
|
|
labelDropShadowDistance: defaultTextStyle.dropShadowDistance,
|
|
fontColor: 0x000000,
|
|
fontFamily: defaultTextStyle.fontFamily,
|
|
fontSize: defaultTextStyle.fontSize,
|
|
fontStyle: defaultTextStyle.fontStyle,
|
|
fontWeight: defaultTextStyle.fontWeight,
|
|
labelStroke: defaultTextStyle.stroke,
|
|
labelStrokeThickness: defaultTextStyle.strokeThickness,
|
|
wordWrap: defaultTextStyle.wordWrap,
|
|
wordWrapWidth: defaultTextStyle.wordWrapWidth,
|
|
labelFill: defaultTextStyle.fill,
|
|
labelFillGradientType: defaultTextStyle.fillGradientType,
|
|
labelFillGradientStops: defaultTextStyle.fillGradientStops,
|
|
fontVariant: defaultTextStyle.fontVariant,
|
|
labelLeading: defaultTextStyle.leading,
|
|
letterSpacing: defaultTextStyle.letterSpacing,
|
|
labelLineHeight: defaultTextStyle.lineHeight,
|
|
labelLineJoin: defaultTextStyle.lineJoin,
|
|
labelMiterLimit: defaultTextStyle.miterLimit,
|
|
labelPadding: defaultTextStyle.padding,
|
|
labelTrim: defaultTextStyle.trim,
|
|
textBaseline: defaultTextStyle.textBaseline,
|
|
labelWhiteSpace: defaultTextStyle.whiteSpace
|
|
},
|
|
opts
|
|
);
|
|
}
|
|
|
|
get textureParameters() {
|
|
return ['icon']
|
|
}
|
|
|
|
setTexture(parameter, key, path) {
|
|
let obj = (this[parameter] = {});
|
|
obj._key = key;
|
|
obj._path = path;
|
|
obj._texture = null;
|
|
|
|
obj.initialize = function(texture) {
|
|
if (!texture) console.error('Could not initialize texture. Invalid value provided: ', texture);
|
|
this._texture = texture;
|
|
}.bind(obj);
|
|
|
|
obj.__defineGetter__('texture', function() {
|
|
if (!obj._texture) {
|
|
console.error('Texture was not yet initialized! ', obj);
|
|
} else {
|
|
return obj._texture
|
|
}
|
|
});
|
|
|
|
obj.__defineGetter__('key', () => {
|
|
return obj._key
|
|
});
|
|
obj.__defineGetter__('path', () => {
|
|
return obj._path
|
|
});
|
|
}
|
|
|
|
apply(parameter, func, obj = null) {
|
|
if (obj == null) return
|
|
if (Array.isArray(obj)) {
|
|
obj.forEach(child => {
|
|
if (typeof child == 'object') this.apply(parameter, func, child);
|
|
});
|
|
} else {
|
|
for (let [key, val] of Object.entries(obj)) {
|
|
if (val == null) continue
|
|
if (key == parameter) {
|
|
func.call(obj, val);
|
|
}
|
|
|
|
if (typeof val == 'object') {
|
|
this.apply(parameter, func, val);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates an overlay from the current Object.
|
|
*
|
|
* @param {settings} object - Object to configure the creation process.
|
|
* @param {settings.excludeItems} function - You may provide a function, that excludes elements selectively. The function must return true for items to exclude them.
|
|
* @param {settings.adjustItems} function - You may adjust the created PIXI items, using the adjustItems function.
|
|
* @returns {GeoLayer} - Returns the created GeoLayer.
|
|
* @memberof Overlay
|
|
*/
|
|
create({
|
|
excludeItems = item => {
|
|
return false
|
|
},
|
|
informationCallback = null,
|
|
adjustItems = null,
|
|
cleanupItems = null
|
|
} = {}) {
|
|
const name = this.name[0].toUpperCase() + this.name.slice(1).toLowerCase() + ' Overlay';
|
|
let geoLayer = new GeoLayer(new PIXI.Container(), { name });
|
|
geoLayer.visibility = this.zoomVisibility;
|
|
|
|
if (this.rescale) geoLayer.rescale = this.rescale;
|
|
this.items.forEach(item => {
|
|
if (!excludeItems(item)) {
|
|
if (adjustItems) {
|
|
adjustItems(item);
|
|
}
|
|
|
|
item.overlay = this;
|
|
let graphics = this.createItem(item, informationCallback);
|
|
geoLayer.addChild(graphics);
|
|
if (cleanupItems) {
|
|
cleanupItems(item);
|
|
}
|
|
}
|
|
});
|
|
return geoLayer
|
|
}
|
|
getItemProperty(item, property) {
|
|
let propertyValue = null;
|
|
const propertyExistsOnItem = item[property] !== undefined;
|
|
const propertyExistsOnOverlay = this[property] !== undefined;
|
|
|
|
if (propertyExistsOnItem) propertyValue = item[property];
|
|
else if (propertyExistsOnOverlay) propertyValue = this[property];
|
|
else {
|
|
console.error(`Invalid property: ${property} on`, item);
|
|
}
|
|
|
|
propertyValue = this._resolveReference(item, propertyValue);
|
|
|
|
return propertyValue
|
|
}
|
|
|
|
_isReference(property) {
|
|
if (property && typeof property == 'object' && property.hasOwnProperty('@property')) return true
|
|
else return false
|
|
}
|
|
|
|
_resolveReference(item, property) {
|
|
if (this._isReference(property)) {
|
|
let referencedProperty = property['@property'];
|
|
return this.getItemProperty(item, referencedProperty)
|
|
} else {
|
|
return property
|
|
}
|
|
}
|
|
|
|
findAllTextures() {
|
|
let textures = [];
|
|
|
|
this.textureParameters.forEach(parameter => {
|
|
if (this.hasOwnProperty(parameter) && this[parameter] != null) {
|
|
textures.push(this[parameter]);
|
|
}
|
|
|
|
this.items.forEach(item => {
|
|
if (item.hasOwnProperty(parameter) && item[parameter] != null) {
|
|
textures.push(item[parameter]);
|
|
}
|
|
});
|
|
});
|
|
|
|
return textures
|
|
}
|
|
|
|
selectTextures(textures) {
|
|
this.textureParameters.forEach(parameter => {
|
|
if (this.hasOwnProperty(parameter) && this[parameter]) {
|
|
let path = this[parameter];
|
|
const texture = textures.get(path);
|
|
if (!texture) console.error('Texture could not be selected: ', path);
|
|
this[parameter] = {
|
|
texture,
|
|
path
|
|
};
|
|
}
|
|
this.items.forEach((item, idx) => {
|
|
if (this.items[idx].hasOwnProperty(parameter) && this.items[idx][parameter]) {
|
|
const path = this.items[idx][parameter];
|
|
const texture = textures.get(path);
|
|
|
|
this.items[idx][parameter] = {
|
|
path,
|
|
texture
|
|
};
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
createItem(item, informationCallback = null) {
|
|
let geographics;
|
|
|
|
/**
|
|
* Use a factory to draw the items, if a type is specified.
|
|
*/
|
|
if (this.type != 'auto') {
|
|
let geographicsFactory = Overlay.requestFactory(this.type);
|
|
if (!geographicsFactory)
|
|
console.error(
|
|
'Invalid Overlay Mode: ' +
|
|
this.type +
|
|
'. Fallback to auto mode. Overlaymodes must be registeres beforehand. Valid modes are: ' +
|
|
Overlay.listFactories().join(',') +
|
|
'.'
|
|
);
|
|
else {
|
|
let actualItem = Object.assign({}, this, item);
|
|
geographics = geographicsFactory(actualItem);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Otherwise draw the default geographics.
|
|
*/
|
|
if (!geographics) {
|
|
if (item.geometry == null || item.geometry == undefined) {
|
|
if (item.location) {
|
|
geographics = this._drawPoint(item, informationCallback);
|
|
const scale = this.getItemProperty(item, 'scale');
|
|
geographics.graphics.scale.set(scale, scale);
|
|
} else console.warn("Item could not be drawn. Specify a 'location' or a 'geometry'.", item);
|
|
} else {
|
|
geographics = this._drawGeometry(item);
|
|
this._drawRoutine(geographics, item, informationCallback);
|
|
}
|
|
|
|
this._drawLabel(item, geographics);
|
|
}
|
|
|
|
return geographics
|
|
}
|
|
|
|
_drawLabel(item, geographics) {
|
|
let label = this.getItemProperty(item, 'label');
|
|
if (label) {
|
|
let textStyle = this._gatherFontStyle(item);
|
|
let text = new PIXI.Text(label, textStyle);
|
|
|
|
let labelScale = this.getItemProperty(item, 'labelScale');
|
|
text.scale.set(labelScale, labelScale);
|
|
|
|
let position = new PIXI.Point();
|
|
|
|
let align = this.getItemProperty(item, 'labelAlignment');
|
|
if (align == 'left');
|
|
else if (align == 'center') position.set(text.position.x - text.width / 2, text.position.y);
|
|
else if (align == 'right') position.set(text.position.x - text.width, text.position.y);
|
|
else this._logPropertyNotImplemented('labelAlignment', align);
|
|
|
|
let verticalAlign = this.getItemProperty(item, 'labelVerticalAlignment');
|
|
if (verticalAlign == 'underneath') {
|
|
let size = this.getItemProperty(item, 'size');
|
|
let scale = this.getItemProperty(item, 'scale');
|
|
|
|
let labelSpacing = this.getItemProperty(item, 'labelSpacing');
|
|
|
|
position.y += size * scale + labelSpacing;
|
|
} else if (verticalAlign == 'above') {
|
|
let size = this.getItemProperty(item, 'size');
|
|
let scale = this.getItemProperty(item, 'scale');
|
|
|
|
let labelSpacing = this.getItemProperty(item, 'labelSpacing');
|
|
|
|
position.y -= size * scale + text.height + labelSpacing;
|
|
} else this._logPropertyNotImplemented('labelVerticalAlignment', verticalAlign);
|
|
|
|
text.position.set(position.x, position.y);
|
|
|
|
geographics.graphics.addChild(text);
|
|
}
|
|
}
|
|
|
|
_gatherFontStyle(item) {
|
|
return {
|
|
align: this.getItemProperty(item, 'labelMultiLineAlign'),
|
|
breakWords: this.getItemProperty(item, 'labelBreakWords'),
|
|
dropShadow: this.getItemProperty(item, 'labelDropShadow'),
|
|
dropShadowAlpha: this.getItemProperty(item, 'labelDropShadowAlpha'),
|
|
dropShadowAngle: this.getItemProperty(item, 'labelDropShadowAngle'),
|
|
dropShadowBlur: this.getItemProperty(item, 'labelDropShadowBlur'),
|
|
dropShadowColor: this.getItemProperty(item, 'labelDropShadowColor'),
|
|
dropShadowDistance: this.getItemProperty(item, 'labelDropShadowDistance'),
|
|
fontFamily: this.getItemProperty(item, 'fontFamily'),
|
|
fontSize: this.getItemProperty(item, 'fontSize'),
|
|
fontStyle: this.getItemProperty(item, 'fontStyle'),
|
|
fontWeight: this.getItemProperty(item, 'fontWeight'),
|
|
stroke: this.getItemProperty(item, 'labelStroke'),
|
|
strokeThickness: this.getItemProperty(item, 'labelStrokeThickness'),
|
|
wordWrap: this.getItemProperty(item, 'wordWrap'),
|
|
wordWrapWidth: this.getItemProperty(item, 'wordWrapWidth'),
|
|
|
|
fill: this.getItemProperty(item, 'labelFill'),
|
|
fillGradientType: this.getItemProperty(item, 'labelFillGradientType'),
|
|
fillGradientStops: this.getItemProperty(item, 'labelFillGradientStops'),
|
|
fontVariant: this.getItemProperty(item, 'fontVariant'),
|
|
leading: this.getItemProperty(item, 'labelLeading'),
|
|
letterSpacing: this.getItemProperty(item, 'letterSpacing'),
|
|
lineHeight: this.getItemProperty(item, 'labelLineHeight'),
|
|
lineJoin: this.getItemProperty(item, 'labelLineJoin'),
|
|
miterLimit: this.getItemProperty(item, 'labelMiterLimit'),
|
|
padding: this.getItemProperty(item, 'labelPadding'),
|
|
trim: this.getItemProperty(item, 'labelTrim'),
|
|
textBaseline: this.getItemProperty(item, 'textBaseline'),
|
|
whiteSpace: this.getItemProperty(item, 'labelWhiteSpace')
|
|
}
|
|
}
|
|
|
|
_drawGeometry(item) {
|
|
let multiPolygonArray = [];
|
|
|
|
if (Array.isArray(item.geometry))
|
|
item.geometry.forEach(geometry => {
|
|
let polygon = this._drawPolygon(geometry);
|
|
if (polygon != null && polygon != []) multiPolygonArray.push(polygon);
|
|
});
|
|
else {
|
|
let polygon = this._drawPolygon(item.geometry);
|
|
if (polygon != null && polygon != []) multiPolygonArray.push(polygon);
|
|
}
|
|
const overlay = this;
|
|
return new GeoMultiShape(multiPolygonArray, {
|
|
onDraw: function() {
|
|
overlay._fill.call(overlay, this, item);
|
|
}
|
|
})
|
|
}
|
|
|
|
_drawPolygon(geometry) {
|
|
let polygon = [];
|
|
if (geometry.type == 'Polygon') {
|
|
if (GeoJson.validateCoordinates(geometry.type, geometry.coordinates)) {
|
|
polygon = GeoJson.convert(geometry.type, geometry.coordinates);
|
|
} else console.error(`Could not validate coordinates for type '${geometry.type}'.`, geometry.coordinates);
|
|
} else {
|
|
console.warn(`Geometry of type ${geometry.type} is not implemented yet, hence it was ignored.`);
|
|
}
|
|
|
|
return polygon
|
|
}
|
|
|
|
_drawRoutine(geographics, item, informationCallback = null) {
|
|
this._createIcon(geographics, item);
|
|
|
|
const enabled = this.getItemProperty(item, 'enabled');
|
|
if (enabled) this._createInformation(geographics, item, informationCallback);
|
|
}
|
|
|
|
_createIcon(geographics, item) {
|
|
let icon = this.getItemProperty(item, 'icon');
|
|
if (icon) {
|
|
if (icon.texture) {
|
|
let sprite = new PIXI.Sprite(icon.texture);
|
|
|
|
const iconAnchor = this.getItemProperty(item, 'iconAnchor');
|
|
sprite.anchor.set(iconAnchor.x, iconAnchor.y);
|
|
|
|
const iconScale = this.getItemProperty(item, 'iconScale');
|
|
if (iconScale) sprite.scale.set(iconScale, iconScale);
|
|
|
|
const iconOffset = this.getItemProperty(item, 'iconOffset');
|
|
if (iconOffset && iconOffset.x != null && iconOffset.y != null)
|
|
sprite.position.set(iconOffset.x, iconOffset.y);
|
|
|
|
const iconColor = this.getItemProperty(item, 'iconColor');
|
|
if (iconColor) sprite.tint = iconColor;
|
|
|
|
const iconAlpha = this.getItemProperty(item, 'iconAlpha');
|
|
if (iconAlpha) sprite.alpha = iconAlpha;
|
|
|
|
geographics.graphics.addChild(sprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
_createInformation(geographics, item, callback = null) {
|
|
if (item.information) {
|
|
/**
|
|
* SO: The overlay class is quite convenient.
|
|
* But managing the information creation solely inside this
|
|
* class restricts us massively.
|
|
*
|
|
* Maybe a restructuring would be good, that we can handle
|
|
* the creation of information with a callback and can adapt
|
|
* to any occuring situation.
|
|
*
|
|
* e.g.
|
|
*
|
|
* overlay.informationHandler((geo, type, item)=>{
|
|
* switch(type){
|
|
* case "popup":
|
|
* createPopup(item.information)
|
|
* break;
|
|
*
|
|
* ....
|
|
* }
|
|
* })
|
|
*/
|
|
|
|
geographics.graphics.interactive = true;
|
|
const informationType = this.getItemProperty(item, 'informationType');
|
|
|
|
if (callback) callback.call(this, geographics, informationType, item);
|
|
else {
|
|
this.defaulInformationCallback(geographics, informationType, item);
|
|
}
|
|
}
|
|
}
|
|
|
|
defaulInformationCallback(geographics, informationType, item) {
|
|
// Make the items interactive.
|
|
geographics.graphics.interactive = true;
|
|
geographics.graphics.on('pointerdown', () => {
|
|
if (!geographics.active) {
|
|
geographics.active = true;
|
|
|
|
// Create and place the popup.
|
|
let popup = new Popup({
|
|
header: item.name,
|
|
content: item.information,
|
|
maxWidth: 350
|
|
});
|
|
|
|
if (geographics.map) {
|
|
const scale = 1 / geographics.map.scatter.scale;
|
|
popup.scale.set(scale, scale);
|
|
}
|
|
|
|
popup.position.set(
|
|
geographics.graphics.position.x - popup.width / 2,
|
|
geographics.graphics.position.y - popup.height - 20
|
|
);
|
|
|
|
geographics.graphics.parent.addChild(popup);
|
|
|
|
// Remove the items after n milliseconds.
|
|
const removeTime = 2000;
|
|
setTimeout(() => {
|
|
geographics.active = false;
|
|
popup.parent.removeChild(popup);
|
|
}, removeTime);
|
|
}
|
|
});
|
|
}
|
|
|
|
_fill(geographics, item) {
|
|
const enabled = this.getItemProperty(item, 'enabled');
|
|
const color = enabled ? this.getItemProperty(item, 'color') : this.getItemProperty(item, 'disabledColor');
|
|
let alpha = enabled ? this.getItemProperty(item, 'fillAlpha') : this.getItemProperty(item, 'disabledAlpha');
|
|
geographics.graphics.beginFill(color, alpha);
|
|
}
|
|
|
|
_drawPoint(item, informationCallback = null) {
|
|
const overlay = this;
|
|
let geographic = new GeoPoint(item.location, {
|
|
onDraw: function() {
|
|
overlay._fill.call(overlay, this, item);
|
|
const size = overlay.getItemProperty(item, 'size') ? overlay.getItemProperty(item, 'size') : 0;
|
|
this.graphics.drawCircle(0, 0, size);
|
|
overlay._drawRoutine.call(overlay, this, item, informationCallback);
|
|
}
|
|
});
|
|
return geographic
|
|
}
|
|
|
|
_logPropertyNotImplemented(property, value) {
|
|
console.error(`Value '${value}' is not yet implemented in property '${property}'.`);
|
|
}
|
|
}
|
|
|
|
Overlay.Modes = { auto: null };
|
|
|
|
Overlay.createFactory = function(name, geographicsFactory) {
|
|
if (Overlay.Modes.hasOwnProperty(name))
|
|
console.warn(`The mode ${name} was already implemented and was overwritten!`);
|
|
Overlay.Modes[name] = geographicsFactory;
|
|
};
|
|
|
|
Overlay.requestFactory = function(name) {
|
|
return Overlay.Modes[name]
|
|
};
|
|
|
|
Overlay.listFactories = function() {
|
|
let modes = [];
|
|
for (let key of Object.keys(Overlay.Modes)) {
|
|
modes.push(key);
|
|
}
|
|
return modes
|
|
};
|
|
|
|
/* Needed to ensure that rollup.js includes class definitions and the classes
|
|
are visible inside doctests.
|
|
*/
|
|
window.PIXIApp = PIXIApp;
|
|
window.BlurFilter = BlurFilter;
|
|
window.FlipEffect = FlipEffect;
|
|
window.Flippable = Flippable;
|
|
window.DeepZoomInfo = DeepZoomInfo;
|
|
window.DeepZoomImage = DeepZoomImage;
|
|
window.Popover = Popover;
|
|
window.ScatterContainer = ScatterContainer;
|
|
window.DisplayObjectScatter = DisplayObjectScatter;
|
|
window.Command = Command;
|
|
window.RecorderTools = RecorderTools;
|
|
window.Timeline = Timeline;
|
|
window.AppTest = AppTest;
|
|
window.Theme = Theme;
|
|
window.Button = Button$1;
|
|
window.ButtonGroup = ButtonGroup;
|
|
window.Scrollview = Scrollview;
|
|
window.Slider = Slider;
|
|
window.Stylus = Stylus;
|
|
window.Switch = Switch;
|
|
window.Popup = Popup;
|
|
window.PopupMenu = PopupMenu;
|
|
window.Modal = Modal;
|
|
window.Volatile = Volatile;
|
|
window.Message = Message;
|
|
window.Tooltip = Tooltip;
|
|
window.Badge = Badge;
|
|
window.Progress = Progress;
|
|
window.List = List;
|
|
window.LabeledGraphics = LabeledGraphics;
|
|
window.FontInfo = FontInfo;
|
|
window.Text = Text;
|
|
|
|
window.MapData = MapData;
|
|
window.DeepZoomMapData = DeepZoomMapData;
|
|
|
|
window.GeoMap = GeoMap;
|
|
window.ImageMap = ImageMap;
|
|
window.DeepZoomMap = DeepZoomMap;
|
|
window.Projection = {
|
|
Mercator,
|
|
Robinson
|
|
};
|
|
|
|
window.MapView = MapView;
|
|
|
|
window.MapApp = MapApp;
|
|
|
|
window.AdvancedScatterContainer = AdvancedScatterContainer;
|
|
window.RigidContainer = RigidContainer;
|
|
window.CompactScatter = CompactScatter;
|
|
window.CoverScatter = CoverScatter;
|
|
window.MapObjectScatter = MapObjectScatter;
|
|
window.GeoLayer = GeoLayer;
|
|
window.MapLayer = MapLayer;
|
|
|
|
window.GeoGraphics = GeoGraphics;
|
|
window.GeoPoint = GeoPoint;
|
|
window.GeoLine = GeoLine;
|
|
window.GeoShape = GeoShape;
|
|
window.GeoMultiShape = GeoMultiShape;
|
|
|
|
window.Overlay = Overlay;
|
|
window.MapList = MapList;
|
|
|
|
}());
|