StackGenVis: Alignment of Data, Algorithms, and Models for Stacking Ensemble Learning Using Performance Metrics
https://doi.org/10.1109/TVCG.2020.3030352
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
654 lines
19 KiB
654 lines
19 KiB
import Vue from '../../utils/vue'
|
|
import KeyCodes from '../../utils/key-codes'
|
|
import noop from '../../utils/noop'
|
|
import observeDom from '../../utils/observe-dom'
|
|
import { getComponentConfig } from '../../utils/config'
|
|
import { selectAll, reflow, addClass, removeClass, setAttr } from '../../utils/dom'
|
|
import { isBrowser, hasTouchSupport, hasPointerEventSupport } from '../../utils/env'
|
|
import { EVENT_OPTIONS_NO_CAPTURE, eventOn, eventOff } from '../../utils/events'
|
|
import { isUndefined } from '../../utils/inspect'
|
|
import { toInteger } from '../../utils/number'
|
|
import idMixin from '../../mixins/id'
|
|
import normalizeSlotMixin from '../../mixins/normalize-slot'
|
|
|
|
const NAME = 'BCarousel'
|
|
|
|
// Slide directional classes
|
|
const DIRECTION = {
|
|
next: {
|
|
dirClass: 'carousel-item-left',
|
|
overlayClass: 'carousel-item-next'
|
|
},
|
|
prev: {
|
|
dirClass: 'carousel-item-right',
|
|
overlayClass: 'carousel-item-prev'
|
|
}
|
|
}
|
|
|
|
// Fallback Transition duration (with a little buffer) in ms
|
|
const TRANS_DURATION = 600 + 50
|
|
|
|
// Time for mouse compat events to fire after touch
|
|
const TOUCH_EVENT_COMPAT_WAIT = 500
|
|
|
|
// Number of pixels to consider touch move a swipe
|
|
const SWIPE_THRESHOLD = 40
|
|
|
|
// PointerEvent pointer types
|
|
const PointerType = {
|
|
TOUCH: 'touch',
|
|
PEN: 'pen'
|
|
}
|
|
|
|
// Transition Event names
|
|
const TransitionEndEvents = {
|
|
WebkitTransition: 'webkitTransitionEnd',
|
|
MozTransition: 'transitionend',
|
|
OTransition: 'otransitionend oTransitionEnd',
|
|
transition: 'transitionend'
|
|
}
|
|
|
|
// Return the browser specific transitionEnd event name
|
|
const getTransitionEndEvent = el => {
|
|
for (const name in TransitionEndEvents) {
|
|
if (!isUndefined(el.style[name])) {
|
|
return TransitionEndEvents[name]
|
|
}
|
|
}
|
|
// Fallback
|
|
/* istanbul ignore next */
|
|
return null
|
|
}
|
|
|
|
// @vue/component
|
|
export const BCarousel = /*#__PURE__*/ Vue.extend({
|
|
name: NAME,
|
|
mixins: [idMixin, normalizeSlotMixin],
|
|
provide() {
|
|
return { bvCarousel: this }
|
|
},
|
|
model: {
|
|
prop: 'value',
|
|
event: 'input'
|
|
},
|
|
props: {
|
|
labelPrev: {
|
|
type: String,
|
|
default: () => getComponentConfig(NAME, 'labelPrev')
|
|
},
|
|
labelNext: {
|
|
type: String,
|
|
default: () => getComponentConfig(NAME, 'labelNext')
|
|
},
|
|
labelGotoSlide: {
|
|
type: String,
|
|
default: () => getComponentConfig(NAME, 'labelGotoSlide')
|
|
},
|
|
labelIndicators: {
|
|
type: String,
|
|
default: () => getComponentConfig(NAME, 'labelIndicators')
|
|
},
|
|
interval: {
|
|
type: Number,
|
|
default: 5000
|
|
},
|
|
indicators: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
controls: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
noAnimation: {
|
|
// Disable slide/fade animation
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
fade: {
|
|
// Enable cross-fade animation instead of slide animation
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
noWrap: {
|
|
// Disable wrapping/looping when start/end is reached
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
noTouch: {
|
|
// Sniffed by carousel-slide
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
noHoverPause: {
|
|
// Disable pause on hover
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
imgWidth: {
|
|
// Sniffed by carousel-slide
|
|
type: [Number, String]
|
|
// default: undefined
|
|
},
|
|
imgHeight: {
|
|
// Sniffed by carousel-slide
|
|
type: [Number, String]
|
|
// default: undefined
|
|
},
|
|
background: {
|
|
type: String
|
|
// default: undefined
|
|
},
|
|
value: {
|
|
type: Number,
|
|
default: 0
|
|
}
|
|
},
|
|
data() {
|
|
return {
|
|
index: this.value || 0,
|
|
isSliding: false,
|
|
transitionEndEvent: null,
|
|
slides: [],
|
|
direction: null,
|
|
isPaused: !(toInteger(this.interval) > 0),
|
|
// Touch event handling values
|
|
touchStartX: 0,
|
|
touchDeltaX: 0
|
|
}
|
|
},
|
|
computed: {
|
|
numSlides() {
|
|
return this.slides.length
|
|
}
|
|
},
|
|
watch: {
|
|
value(newVal, oldVal) {
|
|
if (newVal !== oldVal) {
|
|
this.setSlide(toInteger(newVal) || 0)
|
|
}
|
|
},
|
|
interval(newVal, oldVal) {
|
|
if (newVal === oldVal) {
|
|
/* istanbul ignore next */
|
|
return
|
|
}
|
|
if (!newVal) {
|
|
// Pausing slide show
|
|
this.pause(false)
|
|
} else {
|
|
// Restarting or Changing interval
|
|
this.pause(true)
|
|
this.start(false)
|
|
}
|
|
},
|
|
isPaused(newVal, oldVal) {
|
|
if (newVal !== oldVal) {
|
|
this.$emit(newVal ? 'paused' : 'unpaused')
|
|
}
|
|
},
|
|
index(to, from) {
|
|
if (to === from || this.isSliding) {
|
|
/* istanbul ignore next */
|
|
return
|
|
}
|
|
this.doSlide(to, from)
|
|
}
|
|
},
|
|
created() {
|
|
// Create private non-reactive props
|
|
this._intervalId = null
|
|
this._animationTimeout = null
|
|
this._touchTimeout = null
|
|
// Set initial paused state
|
|
this.isPaused = !(toInteger(this.interval) > 0)
|
|
},
|
|
mounted() {
|
|
// Cache current browser transitionend event name
|
|
this.transitionEndEvent = getTransitionEndEvent(this.$el) || null
|
|
// Get all slides
|
|
this.updateSlides()
|
|
// Observe child changes so we can update slide list
|
|
observeDom(this.$refs.inner, this.updateSlides.bind(this), {
|
|
subtree: false,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeFilter: ['id']
|
|
})
|
|
},
|
|
beforeDestroy() {
|
|
clearTimeout(this._animationTimeout)
|
|
clearTimeout(this._touchTimeout)
|
|
clearInterval(this._intervalId)
|
|
this._intervalId = null
|
|
this._animationTimeout = null
|
|
this._touchTimeout = null
|
|
},
|
|
methods: {
|
|
// Set slide
|
|
setSlide(slide, direction = null) {
|
|
// Don't animate when page is not visible
|
|
/* istanbul ignore if: difficult to test */
|
|
if (isBrowser && document.visibilityState && document.hidden) {
|
|
return
|
|
}
|
|
const noWrap = this.noWrap
|
|
const numSlides = this.numSlides
|
|
// Make sure we have an integer (you never know!)
|
|
slide = Math.floor(slide)
|
|
// Don't do anything if nothing to slide to
|
|
if (numSlides === 0) {
|
|
return
|
|
}
|
|
// Don't change slide while transitioning, wait until transition is done
|
|
if (this.isSliding) {
|
|
// Schedule slide after sliding complete
|
|
this.$once('sliding-end', () => this.setSlide(slide, direction))
|
|
return
|
|
}
|
|
this.direction = direction
|
|
// Set new slide index
|
|
// Wrap around if necessary (if no-wrap not enabled)
|
|
this.index =
|
|
slide >= numSlides
|
|
? noWrap
|
|
? numSlides - 1
|
|
: 0
|
|
: slide < 0
|
|
? noWrap
|
|
? 0
|
|
: numSlides - 1
|
|
: slide
|
|
// Ensure the v-model is synched up if no-wrap is enabled
|
|
// and user tried to slide pass either ends
|
|
if (noWrap && this.index !== slide && this.index !== this.value) {
|
|
this.$emit('input', this.index)
|
|
}
|
|
},
|
|
// Previous slide
|
|
prev() {
|
|
this.setSlide(this.index - 1, 'prev')
|
|
},
|
|
// Next slide
|
|
next() {
|
|
this.setSlide(this.index + 1, 'next')
|
|
},
|
|
// Pause auto rotation
|
|
pause(evt) {
|
|
if (!evt) {
|
|
this.isPaused = true
|
|
}
|
|
if (this._intervalId) {
|
|
clearInterval(this._intervalId)
|
|
this._intervalId = null
|
|
}
|
|
},
|
|
// Start auto rotate slides
|
|
start(evt) {
|
|
if (!evt) {
|
|
this.isPaused = false
|
|
}
|
|
/* istanbul ignore next: most likely will never happen, but just in case */
|
|
if (this._intervalId) {
|
|
clearInterval(this._intervalId)
|
|
this._intervalId = null
|
|
}
|
|
// Don't start if no interval, or less than 2 slides
|
|
if (this.interval && this.numSlides > 1) {
|
|
this._intervalId = setInterval(this.next, Math.max(1000, this.interval))
|
|
}
|
|
},
|
|
// Restart auto rotate slides when focus/hover leaves the carousel
|
|
restart() /* istanbul ignore next: difficult to test */ {
|
|
if (!this.$el.contains(document.activeElement)) {
|
|
this.start()
|
|
}
|
|
},
|
|
doSlide(to, from) {
|
|
const isCycling = Boolean(this.interval)
|
|
// Determine sliding direction
|
|
const direction = this.calcDirection(this.direction, from, to)
|
|
const overlayClass = direction.overlayClass
|
|
const dirClass = direction.dirClass
|
|
// Determine current and next slides
|
|
const currentSlide = this.slides[from]
|
|
const nextSlide = this.slides[to]
|
|
// Don't do anything if there aren't any slides to slide to
|
|
if (!currentSlide || !nextSlide) {
|
|
/* istanbul ignore next */
|
|
return
|
|
}
|
|
// Start animating
|
|
this.isSliding = true
|
|
if (isCycling) {
|
|
this.pause(false)
|
|
}
|
|
this.$emit('sliding-start', to)
|
|
// Update v-model
|
|
this.$emit('input', this.index)
|
|
if (this.noAnimation) {
|
|
addClass(nextSlide, 'active')
|
|
removeClass(currentSlide, 'active')
|
|
this.isSliding = false
|
|
// Notify ourselves that we're done sliding (slid)
|
|
this.$nextTick(() => this.$emit('sliding-end', to))
|
|
} else {
|
|
addClass(nextSlide, overlayClass)
|
|
// Trigger a reflow of next slide
|
|
reflow(nextSlide)
|
|
addClass(currentSlide, dirClass)
|
|
addClass(nextSlide, dirClass)
|
|
// Transition End handler
|
|
let called = false
|
|
/* istanbul ignore next: difficult to test */
|
|
const onceTransEnd = () => {
|
|
if (called) {
|
|
return
|
|
}
|
|
called = true
|
|
/* istanbul ignore if: transition events cant be tested in JSDOM */
|
|
if (this.transitionEndEvent) {
|
|
const events = this.transitionEndEvent.split(/\s+/)
|
|
events.forEach(evt =>
|
|
eventOff(currentSlide, evt, onceTransEnd, EVENT_OPTIONS_NO_CAPTURE)
|
|
)
|
|
}
|
|
this._animationTimeout = null
|
|
removeClass(nextSlide, dirClass)
|
|
removeClass(nextSlide, overlayClass)
|
|
addClass(nextSlide, 'active')
|
|
removeClass(currentSlide, 'active')
|
|
removeClass(currentSlide, dirClass)
|
|
removeClass(currentSlide, overlayClass)
|
|
setAttr(currentSlide, 'aria-current', 'false')
|
|
setAttr(nextSlide, 'aria-current', 'true')
|
|
setAttr(currentSlide, 'aria-hidden', 'true')
|
|
setAttr(nextSlide, 'aria-hidden', 'false')
|
|
this.isSliding = false
|
|
this.direction = null
|
|
// Notify ourselves that we're done sliding (slid)
|
|
this.$nextTick(() => this.$emit('sliding-end', to))
|
|
}
|
|
// Set up transitionend handler
|
|
/* istanbul ignore if: transition events cant be tested in JSDOM */
|
|
if (this.transitionEndEvent) {
|
|
const events = this.transitionEndEvent.split(/\s+/)
|
|
events.forEach(event =>
|
|
eventOn(currentSlide, event, onceTransEnd, EVENT_OPTIONS_NO_CAPTURE)
|
|
)
|
|
}
|
|
// Fallback to setTimeout()
|
|
this._animationTimeout = setTimeout(onceTransEnd, TRANS_DURATION)
|
|
}
|
|
if (isCycling) {
|
|
this.start(false)
|
|
}
|
|
},
|
|
// Update slide list
|
|
updateSlides() {
|
|
this.pause(true)
|
|
// Get all slides as DOM elements
|
|
this.slides = selectAll('.carousel-item', this.$refs.inner)
|
|
const numSlides = this.slides.length
|
|
// Keep slide number in range
|
|
const index = Math.max(0, Math.min(Math.floor(this.index), numSlides - 1))
|
|
this.slides.forEach((slide, idx) => {
|
|
const n = idx + 1
|
|
if (idx === index) {
|
|
addClass(slide, 'active')
|
|
setAttr(slide, 'aria-current', 'true')
|
|
} else {
|
|
removeClass(slide, 'active')
|
|
setAttr(slide, 'aria-current', 'false')
|
|
}
|
|
setAttr(slide, 'aria-posinset', String(n))
|
|
setAttr(slide, 'aria-setsize', String(numSlides))
|
|
})
|
|
// Set slide as active
|
|
this.setSlide(index)
|
|
this.start(this.isPaused)
|
|
},
|
|
calcDirection(direction = null, curIndex = 0, nextIndex = 0) {
|
|
if (!direction) {
|
|
return nextIndex > curIndex ? DIRECTION.next : DIRECTION.prev
|
|
}
|
|
return DIRECTION[direction]
|
|
},
|
|
handleClick(evt, fn) {
|
|
const keyCode = evt.keyCode
|
|
if (evt.type === 'click' || keyCode === KeyCodes.SPACE || keyCode === KeyCodes.ENTER) {
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
fn()
|
|
}
|
|
},
|
|
handleSwipe() /* istanbul ignore next: JSDOM doesn't support touch events */ {
|
|
const absDeltaX = Math.abs(this.touchDeltaX)
|
|
if (absDeltaX <= SWIPE_THRESHOLD) {
|
|
return
|
|
}
|
|
const direction = absDeltaX / this.touchDeltaX
|
|
// Reset touch delta X
|
|
// https://github.com/twbs/bootstrap/pull/28558
|
|
this.touchDeltaX = 0
|
|
if (direction > 0) {
|
|
// Swipe left
|
|
this.prev()
|
|
} else if (direction < 0) {
|
|
// Swipe right
|
|
this.next()
|
|
}
|
|
},
|
|
touchStart(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ {
|
|
if (hasPointerEventSupport && PointerType[evt.pointerType.toUpperCase()]) {
|
|
this.touchStartX = evt.clientX
|
|
} else if (!hasPointerEventSupport) {
|
|
this.touchStartX = evt.touches[0].clientX
|
|
}
|
|
},
|
|
touchMove(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ {
|
|
// Ensure swiping with one touch and not pinching
|
|
if (evt.touches && evt.touches.length > 1) {
|
|
this.touchDeltaX = 0
|
|
} else {
|
|
this.touchDeltaX = evt.touches[0].clientX - this.touchStartX
|
|
}
|
|
},
|
|
touchEnd(evt) /* istanbul ignore next: JSDOM doesn't support touch events */ {
|
|
if (hasPointerEventSupport && PointerType[evt.pointerType.toUpperCase()]) {
|
|
this.touchDeltaX = evt.clientX - this.touchStartX
|
|
}
|
|
this.handleSwipe()
|
|
// If it's a touch-enabled device, mouseenter/leave are fired as
|
|
// part of the mouse compatibility events on first tap - the carousel
|
|
// would stop cycling until user tapped out of it;
|
|
// here, we listen for touchend, explicitly pause the carousel
|
|
// (as if it's the second time we tap on it, mouseenter compat event
|
|
// is NOT fired) and after a timeout (to allow for mouse compatibility
|
|
// events to fire) we explicitly restart cycling
|
|
this.pause(false)
|
|
if (this._touchTimeout) {
|
|
clearTimeout(this._touchTimeout)
|
|
}
|
|
this._touchTimeout = setTimeout(
|
|
this.start,
|
|
TOUCH_EVENT_COMPAT_WAIT + Math.max(1000, this.interval)
|
|
)
|
|
}
|
|
},
|
|
render(h) {
|
|
// Wrapper for slides
|
|
const inner = h(
|
|
'div',
|
|
{
|
|
ref: 'inner',
|
|
class: ['carousel-inner'],
|
|
attrs: {
|
|
id: this.safeId('__BV_inner_'),
|
|
role: 'list'
|
|
}
|
|
},
|
|
[this.normalizeSlot('default')]
|
|
)
|
|
|
|
// Prev and next controls
|
|
let controls = h()
|
|
if (this.controls) {
|
|
const prevHandler = evt => {
|
|
/* istanbul ignore next */
|
|
if (!this.isSliding) {
|
|
this.handleClick(evt, this.prev)
|
|
} else {
|
|
evt.preventDefault()
|
|
}
|
|
}
|
|
const nextHandler = evt => {
|
|
/* istanbul ignore next */
|
|
if (!this.isSliding) {
|
|
this.handleClick(evt, this.next)
|
|
} else {
|
|
evt.preventDefault()
|
|
}
|
|
}
|
|
controls = [
|
|
h(
|
|
'a',
|
|
{
|
|
class: ['carousel-control-prev'],
|
|
attrs: {
|
|
href: '#',
|
|
role: 'button',
|
|
'aria-controls': this.safeId('__BV_inner_'),
|
|
'aria-disabled': this.isSliding ? 'true' : null
|
|
},
|
|
on: {
|
|
click: prevHandler,
|
|
keydown: prevHandler
|
|
}
|
|
},
|
|
[
|
|
h('span', { class: ['carousel-control-prev-icon'], attrs: { 'aria-hidden': 'true' } }),
|
|
h('span', { class: ['sr-only'] }, [this.labelPrev])
|
|
]
|
|
),
|
|
h(
|
|
'a',
|
|
{
|
|
class: ['carousel-control-next'],
|
|
attrs: {
|
|
href: '#',
|
|
role: 'button',
|
|
'aria-controls': this.safeId('__BV_inner_'),
|
|
'aria-disabled': this.isSliding ? 'true' : null
|
|
},
|
|
on: {
|
|
click: nextHandler,
|
|
keydown: nextHandler
|
|
}
|
|
},
|
|
[
|
|
h('span', { class: ['carousel-control-next-icon'], attrs: { 'aria-hidden': 'true' } }),
|
|
h('span', { class: ['sr-only'] }, [this.labelNext])
|
|
]
|
|
)
|
|
]
|
|
}
|
|
|
|
// Indicators
|
|
const indicators = h(
|
|
'ol',
|
|
{
|
|
class: ['carousel-indicators'],
|
|
directives: [
|
|
{ name: 'show', rawName: 'v-show', value: this.indicators, expression: 'indicators' }
|
|
],
|
|
attrs: {
|
|
id: this.safeId('__BV_indicators_'),
|
|
'aria-hidden': this.indicators ? 'false' : 'true',
|
|
'aria-label': this.labelIndicators,
|
|
'aria-owns': this.safeId('__BV_inner_')
|
|
}
|
|
},
|
|
this.slides.map((slide, n) => {
|
|
return h('li', {
|
|
key: `slide_${n}`,
|
|
class: { active: n === this.index },
|
|
attrs: {
|
|
role: 'button',
|
|
id: this.safeId(`__BV_indicator_${n + 1}_`),
|
|
tabindex: this.indicators ? '0' : '-1',
|
|
'aria-current': n === this.index ? 'true' : 'false',
|
|
'aria-label': `${this.labelGotoSlide} ${n + 1}`,
|
|
'aria-describedby': this.slides[n].id || null,
|
|
'aria-controls': this.safeId('__BV_inner_')
|
|
},
|
|
on: {
|
|
click: evt => {
|
|
this.handleClick(evt, () => {
|
|
this.setSlide(n)
|
|
})
|
|
},
|
|
keydown: evt => {
|
|
this.handleClick(evt, () => {
|
|
this.setSlide(n)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
})
|
|
)
|
|
|
|
const on = {
|
|
mouseenter: this.noHoverPause ? noop : this.pause,
|
|
mouseleave: this.noHoverPause ? noop : this.restart,
|
|
focusin: this.pause,
|
|
focusout: this.restart,
|
|
keydown: evt => {
|
|
if (/input|textarea/i.test(evt.target.tagName)) {
|
|
/* istanbul ignore next */
|
|
return
|
|
}
|
|
const keyCode = evt.keyCode
|
|
if (keyCode === KeyCodes.LEFT || keyCode === KeyCodes.RIGHT) {
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
this[keyCode === KeyCodes.LEFT ? 'prev' : 'next']()
|
|
}
|
|
}
|
|
}
|
|
// Touch support event handlers for environment
|
|
if (!this.noTouch && hasTouchSupport) {
|
|
// Attach appropriate listeners (prepend event name with '&' for passive mode)
|
|
/* istanbul ignore next: JSDOM doesn't support touch events */
|
|
if (hasPointerEventSupport) {
|
|
on['&pointerdown'] = this.touchStart
|
|
on['&pointerup'] = this.touchEnd
|
|
} else {
|
|
on['&touchstart'] = this.touchStart
|
|
on['&touchmove'] = this.touchMove
|
|
on['&touchend'] = this.touchEnd
|
|
}
|
|
}
|
|
|
|
// Return the carousel
|
|
return h(
|
|
'div',
|
|
{
|
|
staticClass: 'carousel',
|
|
class: {
|
|
slide: !this.noAnimation,
|
|
'carousel-fade': !this.noAnimation && this.fade,
|
|
'pointer-event': !this.noTouch && hasTouchSupport && hasPointerEventSupport
|
|
},
|
|
style: { background: this.background },
|
|
attrs: {
|
|
role: 'region',
|
|
id: this.safeId(),
|
|
'aria-busy': this.isSliding ? 'true' : 'false'
|
|
},
|
|
on
|
|
},
|
|
[inner, controls, indicators]
|
|
)
|
|
}
|
|
})
|
|
|