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.
573 lines
14 KiB
573 lines
14 KiB
4 years ago
|
import Decimal from './decimal'
|
||
|
import {
|
||
|
Value,
|
||
|
Mark,
|
||
|
MarkOption,
|
||
|
Marks,
|
||
|
MarksProp,
|
||
|
ProcessProp,
|
||
|
ProcessOption,
|
||
|
MarksFunction,
|
||
|
DotOption,
|
||
|
} from '../typings'
|
||
|
|
||
|
// The distance each slider changes
|
||
|
type DotsPosChangeArray = number[]
|
||
|
|
||
|
export const enum ERROR_TYPE {
|
||
|
VALUE = 1,
|
||
|
INTERVAL,
|
||
|
MIN,
|
||
|
MAX,
|
||
|
ORDER,
|
||
|
}
|
||
|
|
||
|
type ERROR_MESSAGE = { [key in ERROR_TYPE]: string }
|
||
|
export const ERROR_MSG: ERROR_MESSAGE = {
|
||
|
[ERROR_TYPE.VALUE]: 'The type of the "value" is illegal',
|
||
|
[ERROR_TYPE.INTERVAL]:
|
||
|
'The prop "interval" is invalid, "(max - min)" cannot be divisible by "interval"',
|
||
|
[ERROR_TYPE.MIN]: 'The "value" cannot be less than the minimum.',
|
||
|
[ERROR_TYPE.MAX]: 'The "value" cannot be greater than the maximum.',
|
||
|
[ERROR_TYPE.ORDER]:
|
||
|
'When "order" is false, the parameters "minRange", "maxRange", "fixed", "enabled" are invalid.',
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Slider logic control center
|
||
|
*
|
||
|
* @export
|
||
|
* @class Control
|
||
|
*/
|
||
|
export default class Control {
|
||
|
dotsPos: number[] = [] // The position of each slider
|
||
|
dotsValue: Value[] = [] // The value of each slider
|
||
|
|
||
|
data?: Value[]
|
||
|
enableCross: boolean
|
||
|
fixed: boolean
|
||
|
max: number
|
||
|
min: number
|
||
|
interval: number
|
||
|
minRange: number
|
||
|
maxRange: number
|
||
|
order: boolean
|
||
|
marks?: MarksProp
|
||
|
included?: boolean
|
||
|
process?: ProcessProp
|
||
|
adsorb?: boolean
|
||
|
dotOptions?: DotOption | DotOption[]
|
||
|
onError?: (type: ERROR_TYPE, message: string) => void
|
||
|
|
||
|
constructor(options: {
|
||
|
value: Value | Value[]
|
||
|
data?: Value[]
|
||
|
enableCross: boolean
|
||
|
fixed: boolean
|
||
|
max: number
|
||
|
min: number
|
||
|
interval: number
|
||
|
order: boolean
|
||
|
minRange?: number
|
||
|
maxRange?: number
|
||
|
marks?: MarksProp
|
||
|
included?: boolean
|
||
|
process?: ProcessProp
|
||
|
adsorb?: boolean
|
||
|
dotOptions?: DotOption | DotOption[]
|
||
|
onError?: (type: ERROR_TYPE, message: string) => void
|
||
|
}) {
|
||
|
this.data = options.data
|
||
|
this.max = options.max
|
||
|
this.min = options.min
|
||
|
this.interval = options.interval
|
||
|
this.order = options.order
|
||
|
this.marks = options.marks
|
||
|
this.included = options.included
|
||
|
this.process = options.process
|
||
|
this.adsorb = options.adsorb
|
||
|
this.dotOptions = options.dotOptions
|
||
|
this.onError = options.onError
|
||
|
if (this.order) {
|
||
|
this.minRange = options.minRange || 0
|
||
|
this.maxRange = options.maxRange || 0
|
||
|
this.enableCross = options.enableCross
|
||
|
this.fixed = options.fixed
|
||
|
} else {
|
||
|
if (options.minRange || options.maxRange || !options.enableCross || options.fixed) {
|
||
|
this.emitError(ERROR_TYPE.ORDER)
|
||
|
}
|
||
|
this.minRange = 0
|
||
|
this.maxRange = 0
|
||
|
this.enableCross = true
|
||
|
this.fixed = false
|
||
|
}
|
||
|
this.setValue(options.value)
|
||
|
}
|
||
|
|
||
|
setValue(value: Value | Value[]) {
|
||
|
this.setDotsValue(Array.isArray(value) ? [...value] : [value], true)
|
||
|
}
|
||
|
|
||
|
setDotsValue(value: Value[], syncPos?: boolean) {
|
||
|
this.dotsValue = value
|
||
|
if (syncPos) {
|
||
|
this.syncDotsPos()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Set the slider position
|
||
|
setDotsPos(dotsPos: number[]) {
|
||
|
const list = this.order ? [...dotsPos].sort((a, b) => a - b) : dotsPos
|
||
|
this.dotsPos = list
|
||
|
this.setDotsValue(list.map(dotPos => this.getValueByPos(dotPos)), this.adsorb)
|
||
|
}
|
||
|
|
||
|
// Get value by position
|
||
|
getValueByPos(pos: number): Value {
|
||
|
let value = this.parsePos(pos)
|
||
|
// When included is true, the return value is the value of the nearest mark
|
||
|
if (this.included) {
|
||
|
let dir = 100
|
||
|
this.markList.forEach(mark => {
|
||
|
const curDir = Math.abs(mark.pos - pos)
|
||
|
if (curDir < dir) {
|
||
|
dir = curDir
|
||
|
value = mark.value
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
return value
|
||
|
}
|
||
|
|
||
|
// Sync slider position
|
||
|
syncDotsPos() {
|
||
|
this.dotsPos = this.dotsValue.map(v => this.parseValue(v))
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get all the marks
|
||
|
*
|
||
|
* @readonly
|
||
|
* @type {Mark[]}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
get markList(): Mark[] {
|
||
|
if (!this.marks) {
|
||
|
return []
|
||
|
}
|
||
|
|
||
|
const getMarkByValue = (value: Value, mark?: MarkOption): Mark => {
|
||
|
const pos = this.parseValue(value)
|
||
|
return {
|
||
|
pos,
|
||
|
value,
|
||
|
label: value,
|
||
|
active: this.isActiveByPos(pos),
|
||
|
...mark,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (this.marks === true) {
|
||
|
return this.getValues().map(value => getMarkByValue(value))
|
||
|
} else if (Object.prototype.toString.call(this.marks) === '[object Object]') {
|
||
|
return Object.keys(this.marks)
|
||
|
.sort((a, b) => +a - +b)
|
||
|
.map(value => {
|
||
|
const item = (this.marks as Marks)[value]
|
||
|
return getMarkByValue(value, typeof item !== 'string' ? item : { label: item })
|
||
|
})
|
||
|
} else if (Array.isArray(this.marks)) {
|
||
|
return this.marks.map(value => getMarkByValue(value))
|
||
|
} else if (typeof this.marks === 'function') {
|
||
|
return this.getValues()
|
||
|
.map(value => ({ value, result: (this.marks as MarksFunction)(value) }))
|
||
|
.filter(({ result }) => !!result)
|
||
|
.map(({ value, result }) => getMarkByValue(value, result as Mark))
|
||
|
} else {
|
||
|
return []
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the most recent slider index by position
|
||
|
*
|
||
|
* @param {number} pos
|
||
|
* @returns {number}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
getRecentDot(pos: number): number {
|
||
|
const arr = this.dotsPos.map(dotPos => Math.abs(dotPos - pos))
|
||
|
return arr.indexOf(Math.min(...arr))
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get index by value
|
||
|
*
|
||
|
* @param {Value} value
|
||
|
* @returns {number}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
getIndexByValue(value: Value): number {
|
||
|
if (this.data) {
|
||
|
return this.data.indexOf(value)
|
||
|
}
|
||
|
return new Decimal(+value)
|
||
|
.minus(this.min)
|
||
|
.divide(this.interval)
|
||
|
.toNumber()
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get value by index
|
||
|
*
|
||
|
* @param {index} number
|
||
|
* @returns {Value}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
getValueByIndex(index: number): Value {
|
||
|
if (index < 0) {
|
||
|
index = 0
|
||
|
} else if (index > this.total) {
|
||
|
index = this.total
|
||
|
}
|
||
|
return this.data
|
||
|
? this.data[index]
|
||
|
: new Decimal(index)
|
||
|
.multiply(this.interval)
|
||
|
.plus(this.min)
|
||
|
.toNumber()
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set the position of a single slider
|
||
|
*
|
||
|
* @param {number} pos
|
||
|
* @param {number} index
|
||
|
*/
|
||
|
setDotPos(pos: number, index: number) {
|
||
|
pos = this.getValidPos(pos, index).pos
|
||
|
const changePos = pos - this.dotsPos[index]
|
||
|
|
||
|
if (!changePos) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
let changePosArr: DotsPosChangeArray = new Array(this.dotsPos.length)
|
||
|
if (this.fixed) {
|
||
|
changePosArr = this.getFixedChangePosArr(changePos, index)
|
||
|
} else if (this.minRange || this.maxRange) {
|
||
|
changePosArr = this.getLimitRangeChangePosArr(pos, changePos, index)
|
||
|
} else {
|
||
|
changePosArr[index] = changePos
|
||
|
}
|
||
|
|
||
|
this.setDotsPos(this.dotsPos.map((curPos, i) => curPos + (changePosArr[i] || 0)))
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* In fixed mode, get the position of all slider changes
|
||
|
*
|
||
|
* @param {number} changePos Change distance of a single slider
|
||
|
* @param {number} index slider index
|
||
|
* @returns {DotsPosChangeArray}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
private getFixedChangePosArr(changePos: number, index: number): DotsPosChangeArray {
|
||
|
this.dotsPos.forEach((originPos, i) => {
|
||
|
if (i !== index) {
|
||
|
const { pos: lastPos, inRange } = this.getValidPos(originPos + changePos, i)
|
||
|
if (!inRange) {
|
||
|
changePos =
|
||
|
Math.min(Math.abs(lastPos - originPos), Math.abs(changePos)) * (changePos < 0 ? -1 : 1)
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
return this.dotsPos.map(_ => changePos)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* In minRange/maxRange mode, get the position of all slider changes
|
||
|
*
|
||
|
* @param {number} pos position of a single slider
|
||
|
* @param {number} changePos Change distance of a single slider
|
||
|
* @param {number} index slider index
|
||
|
* @returns {DotsPosChangeArray}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
private getLimitRangeChangePosArr(
|
||
|
pos: number,
|
||
|
changePos: number,
|
||
|
index: number,
|
||
|
): DotsPosChangeArray {
|
||
|
const changeDots = [{ index, changePos }]
|
||
|
const newChangePos = changePos
|
||
|
;[this.minRange, this.maxRange].forEach((isLimitRange?: number, rangeIndex?: number) => {
|
||
|
if (!isLimitRange) {
|
||
|
return false
|
||
|
}
|
||
|
const isMinRange = rangeIndex === 0
|
||
|
const isForward = changePos > 0
|
||
|
let next = 0
|
||
|
if (isMinRange) {
|
||
|
next = isForward ? 1 : -1
|
||
|
} else {
|
||
|
next = isForward ? -1 : 1
|
||
|
}
|
||
|
|
||
|
// Determine if the two positions are within the legal interval
|
||
|
const inLimitRange = (pos2: number, pos1: number): boolean => {
|
||
|
const diff = Math.abs(pos2 - pos1)
|
||
|
return isMinRange ? diff < this.minRangeDir : diff > this.maxRangeDir
|
||
|
}
|
||
|
|
||
|
let i = index + next
|
||
|
let nextPos = this.dotsPos[i]
|
||
|
let curPos = pos
|
||
|
while (this.isPos(nextPos) && inLimitRange(nextPos, curPos)) {
|
||
|
const { pos: lastPos } = this.getValidPos(nextPos + newChangePos, i)
|
||
|
changeDots.push({
|
||
|
index: i,
|
||
|
changePos: lastPos - nextPos,
|
||
|
})
|
||
|
i = i + next
|
||
|
curPos = lastPos
|
||
|
nextPos = this.dotsPos[i]
|
||
|
}
|
||
|
})
|
||
|
|
||
|
return this.dotsPos.map((_, i) => {
|
||
|
const changeDot = changeDots.filter(dot => dot.index === i)
|
||
|
return changeDot.length ? changeDot[0].changePos : 0
|
||
|
})
|
||
|
}
|
||
|
|
||
|
private isPos(pos: any): boolean {
|
||
|
return typeof pos === 'number'
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get a valid position by pos
|
||
|
*
|
||
|
* @param {number} pos
|
||
|
* @param {number} index
|
||
|
* @returns {{ pos: number, inRange: boolean }}
|
||
|
*/
|
||
|
getValidPos(pos: number, index: number): { pos: number; inRange: boolean } {
|
||
|
const range = this.valuePosRange[index]
|
||
|
let inRange = true
|
||
|
if (pos < range[0]) {
|
||
|
pos = range[0]
|
||
|
inRange = false
|
||
|
} else if (pos > range[1]) {
|
||
|
pos = range[1]
|
||
|
inRange = false
|
||
|
}
|
||
|
return {
|
||
|
pos,
|
||
|
inRange,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculate the position of the slider by value
|
||
|
*
|
||
|
* @param {Value} val
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
parseValue(val: Value): number {
|
||
|
if (this.data) {
|
||
|
val = this.data.indexOf(val)
|
||
|
} else if (typeof val === 'number' || typeof val === 'string') {
|
||
|
val = +val
|
||
|
if (val < this.min) {
|
||
|
this.emitError(ERROR_TYPE.MIN)
|
||
|
return 0
|
||
|
}
|
||
|
if (val > this.max) {
|
||
|
this.emitError(ERROR_TYPE.MAX)
|
||
|
return 0
|
||
|
}
|
||
|
if (typeof val !== 'number' || val !== val) {
|
||
|
this.emitError(ERROR_TYPE.VALUE)
|
||
|
return 0
|
||
|
}
|
||
|
val = new Decimal(val)
|
||
|
.minus(this.min)
|
||
|
.divide(this.interval)
|
||
|
.toNumber()
|
||
|
}
|
||
|
|
||
|
const pos = new Decimal(val).multiply(this.gap).toNumber()
|
||
|
return pos < 0 ? 0 : pos > 100 ? 100 : pos
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculate the value by position
|
||
|
*
|
||
|
* @param {number} pos
|
||
|
* @returns {Value}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
parsePos(pos: number): Value {
|
||
|
const index = Math.round(pos / this.gap)
|
||
|
return this.getValueByIndex(index)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determine if the location is active
|
||
|
*
|
||
|
* @param {number} pos
|
||
|
* @returns {boolean}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
isActiveByPos(pos: number): boolean {
|
||
|
return this.processArray.some(([start, end]) => pos >= start && pos <= end)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get each value
|
||
|
*
|
||
|
* @returns {Value[]}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
getValues(): Value[] {
|
||
|
if (this.data) {
|
||
|
return this.data
|
||
|
} else {
|
||
|
const values: Value[] = []
|
||
|
for (let i = 0; i <= this.total; i++) {
|
||
|
values.push(
|
||
|
new Decimal(i)
|
||
|
.multiply(this.interval)
|
||
|
.plus(this.min)
|
||
|
.toNumber(),
|
||
|
)
|
||
|
}
|
||
|
return values
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculate the distance of the range
|
||
|
* @param range number
|
||
|
*/
|
||
|
getRangeDir(range: number): number {
|
||
|
return range
|
||
|
? new Decimal(range)
|
||
|
.divide(
|
||
|
new Decimal(this.data ? this.data.length - 1 : this.max)
|
||
|
.minus(this.data ? 0 : this.min)
|
||
|
.toNumber(),
|
||
|
)
|
||
|
.multiply(100)
|
||
|
.toNumber()
|
||
|
: 100
|
||
|
}
|
||
|
|
||
|
private emitError(type: ERROR_TYPE) {
|
||
|
if (this.onError) {
|
||
|
this.onError(type, ERROR_MSG[type])
|
||
|
}
|
||
|
}
|
||
|
|
||
|
get processArray(): ProcessOption {
|
||
|
if (this.process) {
|
||
|
if (typeof this.process === 'function') {
|
||
|
return this.process(this.dotsPos)
|
||
|
} else if (this.dotsPos.length === 1) {
|
||
|
return [[0, this.dotsPos[0]]]
|
||
|
} else if (this.dotsPos.length > 1) {
|
||
|
return [[Math.min(...this.dotsPos), Math.max(...this.dotsPos)]]
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return []
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* The total number of values
|
||
|
*
|
||
|
* @type {number}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
get total(): number {
|
||
|
let total = 0
|
||
|
if (this.data) {
|
||
|
total = this.data.length - 1
|
||
|
} else {
|
||
|
total = new Decimal(this.max)
|
||
|
.minus(this.min)
|
||
|
.divide(this.interval)
|
||
|
.toNumber()
|
||
|
}
|
||
|
if (total - Math.floor(total) !== 0) {
|
||
|
this.emitError(ERROR_TYPE.INTERVAL)
|
||
|
return 0
|
||
|
}
|
||
|
return total
|
||
|
}
|
||
|
|
||
|
// Distance between each value
|
||
|
get gap(): number {
|
||
|
return 100 / this.total
|
||
|
}
|
||
|
|
||
|
private cacheRangeDir: { [key: string]: number } = {}
|
||
|
|
||
|
// The minimum distance between the two sliders
|
||
|
get minRangeDir(): number {
|
||
|
if (this.cacheRangeDir[this.minRange]) {
|
||
|
return this.cacheRangeDir[this.minRange]
|
||
|
}
|
||
|
return (this.cacheRangeDir[this.minRange] = this.getRangeDir(this.minRange))
|
||
|
}
|
||
|
|
||
|
// Maximum distance between the two sliders
|
||
|
get maxRangeDir(): number {
|
||
|
if (this.cacheRangeDir[this.maxRange]) return this.cacheRangeDir[this.maxRange]
|
||
|
return (this.cacheRangeDir[this.maxRange] = this.getRangeDir(this.maxRange))
|
||
|
}
|
||
|
|
||
|
private getDotRange(index: number, key: 'min' | 'max', defaultValue: number): number {
|
||
|
if (!this.dotOptions) {
|
||
|
return defaultValue
|
||
|
}
|
||
|
|
||
|
const option = Array.isArray(this.dotOptions) ? this.dotOptions[index] : this.dotOptions
|
||
|
return option && option[key] !== void 0 ? this.parseValue(option[key] as Value) : defaultValue
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Sliding range of each slider
|
||
|
*
|
||
|
* @type {Array<[number, number]>}
|
||
|
* @memberof Control
|
||
|
*/
|
||
|
get valuePosRange(): Array<[number, number]> {
|
||
|
const dotsPos = this.dotsPos
|
||
|
const valuePosRange: Array<[number, number]> = []
|
||
|
|
||
|
dotsPos.forEach((pos, i) => {
|
||
|
valuePosRange.push([
|
||
|
Math.max(
|
||
|
this.minRange ? this.minRangeDir * i : 0,
|
||
|
!this.enableCross ? dotsPos[i - 1] || 0 : 0,
|
||
|
this.getDotRange(i, 'min', 0),
|
||
|
),
|
||
|
Math.min(
|
||
|
this.minRange ? 100 - this.minRangeDir * (dotsPos.length - 1 - i) : 100,
|
||
|
!this.enableCross ? dotsPos[i + 1] || 100 : 100,
|
||
|
this.getDotRange(i, 'max', 100),
|
||
|
),
|
||
|
])
|
||
|
})
|
||
|
|
||
|
return valuePosRange
|
||
|
}
|
||
|
|
||
|
get dotsIndex(): number[] {
|
||
|
return this.dotsValue.map(val => this.getIndexByValue(val))
|
||
|
}
|
||
|
}
|