diff --git a/js/controller.js b/js/controller.js index aeed3fcf..4f66cdaa 100644 --- a/js/controller.js +++ b/js/controller.js @@ -1,9 +1,11 @@ export default class Controller { - constructor(platform) { - this._e = platform.setting.ml.configElement.node() + constructor(platform, root) { + this._platform = platform + this._e = root ?? platform.setting.ml.configElement.node() this._terminators = [] platform.setting.terminate = this.terminate.bind(this) + this.input = this.input.bind(this) this.input.text = conf => { return this.input({ type: 'text', ...conf }) @@ -22,10 +24,20 @@ export default class Controller { } } + get element() { + return this._e + } + terminate() { this._terminators.forEach(t => t()) } + span() { + const span = document.createElement('span') + this._e.appendChild(span) + return new Controller(this._platform, span) + } + text(conf = {}) { if (typeof conf === 'string') { conf = { value: conf } diff --git a/js/data/manual.js b/js/data/manual.js index 165f76e0..06e22597 100644 --- a/js/data/manual.js +++ b/js/data/manual.js @@ -1,6 +1,7 @@ import { BaseData } from './base.js' import Matrix from '../../lib/util/matrix.js' -import { specialCategory, getCategoryColor, DataPoint } from '../utils.js' +import { specialCategory, getCategoryColor } from '../utils.js' +import { DataPoint } from '../renderer/util/figure.js' const normal_random = function (m = 0, s = 1) { const std = Math.sqrt(s) diff --git a/js/renderer/line.js b/js/renderer/line.js index 4a87c887..fb71f538 100644 --- a/js/renderer/line.js +++ b/js/renderer/line.js @@ -1,5 +1,6 @@ import BaseRenderer from './base.js' -import { DataPoint, specialCategory, getCategoryColor } from '../utils.js' +import { specialCategory, getCategoryColor } from '../utils.js' +import { DataPoint } from './util/figure.js' const scale = function (v, smin, smax, dmin, dmax) { if (!isFinite(smin) || !isFinite(smax) || smin === smax) { diff --git a/js/renderer/scatter.js b/js/renderer/scatter.js index 9cdc5a49..6e97b141 100644 --- a/js/renderer/scatter.js +++ b/js/renderer/scatter.js @@ -1,5 +1,6 @@ import BaseRenderer from './base.js' -import { getCategoryColor, specialCategory, DataPoint, DataCircle, DataLine, DataHulls } from '../utils.js' +import { getCategoryColor, specialCategory } from '../utils.js' +import { DataPoint, DataCircle, DataLine, DataHulls } from './util/figure.js' import Matrix from '../../lib/util/matrix.js' const scale = function (v, smin, smax, dmin, dmax) { diff --git a/js/renderer/util/centroids.js b/js/renderer/util/centroids.js index 74e3dcac..82bead28 100644 --- a/js/renderer/util/centroids.js +++ b/js/renderer/util/centroids.js @@ -1,4 +1,4 @@ -import { DataPointStarPlotter, DataPoint, DataLine } from '../../utils.js' +import { DataPointStarPlotter, DataPoint, DataLine } from './figure.js' export default class CentroidPlotter { constructor(renderer) { diff --git a/js/renderer/util/figure.js b/js/renderer/util/figure.js new file mode 100644 index 00000000..9f90f53d --- /dev/null +++ b/js/renderer/util/figure.js @@ -0,0 +1,709 @@ +import { getCategoryColor, specialCategory } from '../../utils.js' + +class DataPointCirclePlotter { + constructor(svg, item) { + this._svg = svg + this.item = item + if (!item) { + this.item = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + this._svg.append(this.item) + } + } + + attr(name, value) { + if (value !== undefined) { + this.item.setAttribute(name, value) + return this + } else { + return this.item.getAttribute(name) + } + } + + cx(value) { + return this.attr('cx', value) + } + + cy(value) { + return this.attr('cy', value) + } + + color(value) { + return this.attr('fill', value) + } + + radius(value) { + return this.attr('r', value) + } + + title(value) { + this.item.replaceChildren() + if (value && value !== '') { + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title') + this.item.append(title) + title.replaceChildren(value) + } + return this + } + + remove() { + return this.item.remove() + } +} + +export class DataPointStarPlotter { + constructor(svg, item, polygon) { + this._svg = svg + this._c = [0, 0] + this._r = 5 + if (item) { + this.g = item + this.polygon = polygon + } else { + this.g = document.createElementNS('http://www.w3.org/2000/svg', 'g') + this._svg.append(this.g) + this.polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + this.g.append(this.polygon) + this.polygon.setAttribute('points', this._path()) + this.polygon.setAttribute('stroke', 'black') + } + } + + _path() { + return [ + [-Math.sin((Math.PI * 2) / 5), -Math.cos((Math.PI * 2) / 5)], + [-Math.sin(Math.PI / 5) / 2, -Math.cos(Math.PI / 5) / 2], + [0, -1], + [Math.sin(Math.PI / 5) / 2, -Math.cos(Math.PI / 5) / 2], + [Math.sin((Math.PI * 2) / 5), -Math.cos((Math.PI * 2) / 5)], + [Math.sin((Math.PI * 3) / 5) / 2, -Math.cos((Math.PI * 3) / 5) / 2], + [Math.sin((Math.PI * 4) / 5), -Math.cos((Math.PI * 4) / 5)], + [0, 1 / 2], + [-Math.sin((Math.PI * 4) / 5), -Math.cos((Math.PI * 4) / 5)], + [-Math.sin((Math.PI * 3) / 5) / 2, -Math.cos((Math.PI * 3) / 5) / 2], + ].reduce((acc, v) => acc + v[0] * this._r + ',' + v[1] * this._r + ' ', '') + } + + cx(value) { + this._c[0] = value || this._c[0] + if (value !== undefined) { + this.g.setAttribute('transform', 'translate(' + this._c[0] + ', ' + this._c[1] + ')') + return this + } + return this._c[0] + } + + cy(value) { + this._c[1] = value || this._c[1] + if (value !== undefined) { + this.g.setAttribute('transform', 'translate(' + this._c[0] + ', ' + this._c[1] + ')') + return this + } + return this._c[1] + } + + color(value) { + if (value !== undefined) { + this.polygon.setAttribute('fill', value) + return this + } + return this.polygon.getAttribute('fill') + } + + radius(value) { + this._r = value || this._r + if (value !== undefined) { + this.polygon.setAttribute('points', this._path()) + return this + } + return this._r + } + + title(value) { + this.polygon.replaceChildren() + if (value && value !== '') { + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title') + this.polygon.append(title) + title.replaceChildren(value) + } + return this + } + + duration(value) { + this.g.style.transitionDuration = value + 'ms' + this.g.style.transitionTimingFunction = 'linear' + this.polygon.style.transitionDuration = value + 'ms' + this.polygon.style.transitionTimingFunction = 'linear' + return this + } + + remove() { + return this.g.remove() + } +} + +class DataVector { + constructor(value) { + this.value = value instanceof DataVector ? value.value : value + } + + get length() { + return Math.sqrt(this.value.reduce((acc, v) => acc + v * v, 0)) + } + + map(func) { + return new DataVector(this.value.map(func)) + } + + reduce(func, init) { + return this.value.reduce(func, init) + } + + add(p) { + return this.map((v, i) => v + p.value[i]) + } + + sub(p) { + return this.map((v, i) => v - p.value[i]) + } + + mult(n) { + return this.map(v => v * n) + } + + div(n) { + return this.map(v => v / n) + } + + dot(p) { + return this.value.reduce((acc, v, i) => acc + v * p.value[i], 0) + } + + distance(p) { + return Math.sqrt(this.value.reduce((acc, v, i) => acc + (v - p.value[i]) ** 2, 0)) + } + + angleCos(p) { + return this.dot(p) / (this.length * p.length) + } + + equals(p) { + return this.value.every((v, i) => v === p.value[i]) + } +} + +export class DataPoint { + constructor(svg, position = [0, 0], category = 0) { + this.svg = svg + this.vector = new DataVector(position) + this._color = getCategoryColor(category) + this._category = category + this._radius = 5 + this._plotter = new DataPointCirclePlotter(this.svg) + this._binds = [] + this.display() + } + + display() { + this._plotter + .cx('' + this.vector.value[0]) + .cy('' + this.vector.value[1]) + .radius(this._radius) + .color(this._color) + this._binds.forEach(e => e.display()) + } + + get item() { + return this._plotter.item + } + + get at() { + return this.vector.value + } + set at(position) { + this.vector = new DataVector(position) + this.display() + } + get color() { + return this._color + } + get category() { + return this._category + } + set category(category) { + this._category = category + this._color = getCategoryColor(category) + this.display() + } + get radius() { + return this._radius + } + set radius(radius) { + this._radius = radius + this.display() + } + set title(value) { + this._plotter.title(value) + } + + plotter(plt) { + this._plotter.remove() + this._plotter = new plt(this.svg) + this.display() + } + + remove() { + this._plotter.remove() + this._binds.forEach(e => e.remove()) + } + + move(to, duration = 1000) { + this.vector = new DataVector(to) + this._plotter.duration(duration).cx(this.vector.value[0]).cy(this.vector.value[1]) + this._binds.forEach(e => e.move(duration)) + } + + distance(p) { + return this.vector.distance(p.vector) + } + + bind(e) { + this._binds.push(e) + } + + removeBind(e) { + this._binds = this._binds.filter(b => b !== e) + } + + static sum(arr) { + return arr.length === 0 ? [] : arr.slice(1).reduce((acc, v) => acc.add(v.vector), arr[0].vector) + } + static mean(arr) { + return arr.length === 0 ? [] : DataPoint.sum(arr).div(arr.length) + } +} + +export class DataCircle { + constructor(svg, at) { + this._svg = svg + this.item = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + this._svg.append(this.item) + this.item.setAttribute('fill-opacity', 0) + this._at = at + this._color = null + this._width = 4 + at.bind(this) + this.display() + } + + get color() { + return this._color || this._at.color + } + set color(value) { + this._color = value + this.display() + } + + set title(value) { + this.item.replaceChildren() + if (value && value.length > 0) { + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title') + this.item.append(title) + title.replaceChildren(value) + } + } + + display() { + this.item.setAttribute('cx', this._at.at[0]) + this.item.setAttribute('cy', this._at.at[1]) + this.item.setAttribute('stroke', this.color) + this.item.setAttribute('stroke-width', this._width) + this.item.setAttribute('r', this._at._radius) + } + + remove() { + this.item.remove() + this._at.removeBind(this) + } +} + +export class DataLine { + constructor(svg, from, to) { + this._svg = svg + this.item = document.createElementNS('http://www.w3.org/2000/svg', 'line') + this._svg.append(this.item) + this._from = from + this._to = to + this._remove_listener = null + from && from.bind(this) + to && to.bind(this) + this.display() + } + + set from(value) { + this._from && this._from.removeBind(this) + this._from = value + this._from.bind(this) + } + + set to(value) { + this._to && this._to.removeBind(this) + this._to = value + this._to.bind(this) + } + + display() { + if (!this._from || !this._to) return + this.item.setAttribute('x1', this._from.at[0]) + this.item.setAttribute('y1', this._from.at[1]) + this.item.setAttribute('x2', this._to.at[0]) + this.item.setAttribute('y2', this._to.at[1]) + this.item.setAttribute('stroke', this._from.color) + } + + move(duration = 1000) { + if (!this._from || !this._to) return + if (duration === 0) { + this.display() + return + } + const fromx1 = +this.item.getAttribute('x1') + const fromy1 = +this.item.getAttribute('y1') + const fromx2 = +this.item.getAttribute('x2') + const fromy2 = +this.item.getAttribute('y2') + const dx1 = this._from.at[0] - fromx1 + const dy1 = this._from.at[1] - fromy1 + const dx2 = this._to.at[0] - fromx2 + const dy2 = this._to.at[1] - fromy2 + + let start = 0 + let prev = 0 + const step = timestamp => { + if (!start) { + start = timestamp + } + const elp = Math.min(1, (timestamp - start) / duration) + if (Math.abs(timestamp - prev) > 15) { + if (dx1 !== 0) this.item.setAttribute('x1', fromx1 + dx1 * elp) + if (dy1 !== 0) this.item.setAttribute('y1', fromy1 + dy1 * elp) + if (dx2 !== 0) this.item.setAttribute('x2', fromx2 + dx2 * elp) + if (dy2 !== 0) this.item.setAttribute('y2', fromy2 + dy2 * elp) + if (elp >= 1) { + return + } + prev = timestamp + } + requestAnimationFrame(step) + } + requestAnimationFrame(step) + } + + remove() { + this.item.remove() + this._from && this._from.removeBind(this) + this._from = null + this._to && this._to.removeBind(this) + this._to = null + this._remove_listener && this._remove_listener(this) + } + + setRemoveListener(cb) { + this._remove_listener = cb + } +} + +export class DataConvexHull { + constructor(svg, points) { + this._svg = svg + this.item = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + this._svg.append(this.item) + this._points = points + this._color = null + this.display() + } + + get color() { + return this._color || this._points[0].color + } + set color(value) { + this._color = value + this.display() + } + + _argmin(arr, key) { + if (arr.length === 0) { + return -1 + } + arr = key ? arr.map(key) : arr + return arr.indexOf(Math.min(...arr)) + } + + _convexPoints() { + if (this._points.length <= 3) { + return this._points + } + let cp = [].concat(this._points) + let basei = this._argmin(cp, p => p.at[1]) + const base = cp.splice(basei, 1)[0] + cp.sort((a, b) => { + let dva = a.vector.sub(base.vector) + let dvb = b.vector.sub(base.vector) + return dva.value[0] / dva.length - dvb.value[0] / dvb.length + }) + let outers = [base] + for (let k = 0; k < cp.length; k++) { + while (outers.length >= 3) { + let n = outers.length + const v = outers[n - 1].vector.sub(outers[n - 2].vector).value + const newv = cp[k].vector.sub(outers[n - 2].vector).value + const basev = base.vector.sub(outers[n - 2].vector).value + if ((v[0] * basev[1] - v[1] * basev[0]) * (v[0] * newv[1] - v[1] * newv[0]) > 0) { + break + } + outers.pop() + } + outers.push(cp[k]) + } + return outers + } + + display() { + let points = this._convexPoints().reduce((acc, p) => acc + p.at[0] + ',' + p.at[1] + ' ', '') + this.item.setAttribute('points', points) + this.item.setAttribute('stroke', this.color) + this.item.setAttribute('fill', this.color) + this.item.setAttribute('opacity', 0.5) + } + + remove() { + this.item.remove() + } +} + +class DataMap { + constructor() { + this._data = [] + this._size = [0, 0] + } + + get rows() { + return this._size[0] + } + + get cols() { + return this._size[1] + } + + at(x, y) { + return x < 0 || !this._data[x] || y < 0 ? undefined : this._data[x][y] + } + + set(x, y, value) { + if (!this._data[x]) this._data[x] = [] + this._data[x][y] = value + this._size[0] = Math.max(this._size[0], x + 1) + this._size[1] = Math.max(this._size[1], y + 1) + } +} + +export class DataHulls { + constructor(svg, categories, tileSize, use_canvas = false, mousemove = null) { + this._svg = svg + this._categories = categories + this._tileSize = tileSize + if (!Array.isArray(this._tileSize)) { + this._tileSize = [this._tileSize, this._tileSize] + } + this._use_canvas = use_canvas + this._mousemove = mousemove + this.display() + } + + display() { + if (this._use_canvas) { + const root_svg = document.querySelector('#plot-area svg') + const canvas = document.createElement('canvas') + canvas.width = root_svg.getBoundingClientRect().width + canvas.height = root_svg.getBoundingClientRect().height + let ctx = canvas.getContext('2d') + for (let i = 0; i < this._categories.length; i++) { + for (let j = 0; j < this._categories[i].length; j++) { + ctx.fillStyle = getCategoryColor(this._categories[i][j]) + ctx.fillRect( + Math.round(j * this._tileSize[0]), + Math.round(i * this._tileSize[1]), + Math.ceil(this._tileSize[0]), + Math.ceil(this._tileSize[1]) + ) + } + } + let o = this + const img = document.createElementNS('http://www.w3.org/2000/svg', 'image') + this._svg.append(img) + img.setAttribute('x', 0) + img.setAttribute('y', 0) + img.setAttribute('width', canvas.width) + img.setAttribute('height', canvas.height) + img.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', canvas.toDataURL()) + img.onmousemove = e => { + const mousePos = d3.pointer(e) + this._mousemove && + this._mousemove( + o._categories[Math.round(mousePos[1] / o._tileSize)][Math.round(mousePos[0] / o._tileSize)] + ) + } + return + } + let categories = new DataMap() + for (let i = 0; i < this._categories.length; i++) { + for (let j = 0; j < this._categories[i].length; j++) { + if (this._categories[i][j] === null) { + categories.set(i, j, null) + } else { + categories.set(i, j, Math.round(this._categories[i][j])) + } + } + } + const invalid = [] + for (let i = 0; i < categories.rows; i++) { + for (let j = 0; j < categories.cols; j++) { + if (categories.at(i, j) <= specialCategory.never) { + continue + } + let targetCategory = categories.at(i, j) + let targets = new DataMap() + let hulls = new DataMap() + let checkTargets = [[i, j]] + let ignore = false + while (checkTargets.length > 0) { + let [y, x] = checkTargets.pop() + if (categories.at(y, x) === targetCategory) { + targets.set(y, x, 1) + categories.set(y, x, specialCategory.never) + checkTargets.push([y - 1, x]) + checkTargets.push([y + 1, x]) + checkTargets.push([y, x - 1]) + checkTargets.push([y, x + 1]) + hulls.set( + y, + x, + (targets.at(y - 1, x) !== 1 && categories.at(y - 1, x) !== targetCategory) || + (targets.at(y + 1, x) !== 1 && categories.at(y + 1, x) !== targetCategory) || + (targets.at(y, x - 1) !== 1 && categories.at(y, x - 1) !== targetCategory) || + (targets.at(y, x + 1) !== 1 && categories.at(y, x + 1) !== targetCategory) + ) + } else if (categories.at(y, x) === undefined && targetCategory === null) { + ignore = true + } + } + if (ignore) continue + let hullPoints = [[i, j]] + let y = i, + x = j + 1 + const max_count = categories.rows * categories.cols + let count = 0 + let ori = 'r' + while (y != i || x != j) { + let lt = targets.at(y - 1, x - 1) + let rt = targets.at(y - 1, x) + let lb = targets.at(y, x - 1) + let rb = targets.at(y, x) + if (rt && lt && lb && rb) { + invalid.push([y, x]) + break + } else if (rt && lt && lb) { + hullPoints.push([y, x]) + ori = 'b' + } else if (lt && lb && rb) { + hullPoints.push([y, x]) + ori = 'r' + } else if (lb && rb && rt) { + hullPoints.push([y, x]) + ori = 't' + } else if (rb && rt && lt) { + hullPoints.push([y, x]) + ori = 'l' + } else if (rt && lt) { + ori = 'l' + } else if (lt && lb) { + ori = 'b' + } else if (lb && rb) { + ori = 'r' + } else if (rb && rt) { + ori = 't' + } else if (rt && lb) { + hullPoints.push([y, x]) + if (ori === 'l') { + ori = 't' + } else if (ori === 'r') { + ori = 'b' + } else { + invalid.push([y, x]) + } + } else if (lt && rb) { + hullPoints.push([y, x]) + if (ori === 't') { + ori = 'r' + } else if (ori === 'b') { + ori = 'l' + } else { + invalid.push([y, x]) + } + } else if (rt) { + hullPoints.push([y, x]) + ori = 't' + } else if (lt) { + hullPoints.push([y, x]) + ori = 'l' + } else if (lb) { + hullPoints.push([y, x]) + ori = 'b' + } else if (rb) { + hullPoints.push([y, x]) + ori = 'r' + } else { + invalid.push([y, x]) + break + } + if (ori === 'r') { + x += 1 + } else if (ori === 'l') { + x -= 1 + } else if (ori === 'b') { + y += 1 + } else if (ori === 't') { + y -= 1 + } + count += 1 + if (count >= max_count) { + invalid.push([y, x]) + break + } + } + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + this._svg.append(polygon) + polygon.setAttribute( + 'points', + hullPoints.reduce( + (acc, p) => acc + p[1] * this._tileSize[0] + ',' + p[0] * this._tileSize[1] + ' ', + '' + ) + ) + polygon.setAttribute('fill', targetCategory === null ? 'white' : getCategoryColor(targetCategory)) + } + } + + if (invalid.length > 0) { + let s = '' + if (invalid.length > 100) { + s = '[' + s += invalid.slice(0, 50).map(JSON.stringify).join(',') + s += ',...,' + s += invalid.slice(-50).map(JSON.stringify).join(',') + s += ']' + } else { + s = JSON.stringify(invalid) + } + console.log('invalid loop condition at ' + s) + } + } +} diff --git a/js/utils.js b/js/utils.js index 66dcf146..c014ce78 100644 --- a/js/utils.js +++ b/js/utils.js @@ -20,181 +20,18 @@ export class BaseWorker { } } -class DataPointCirclePlotter { - constructor(svg, item) { - this._svg = svg - this.item = item || this._svg.append('circle') - } - - attr(name, value) { - return value !== undefined ? this.item.attr(name, value) && this : this.item.attr(name) - } - - cx(value) { - return this.attr('cx', value) - } - - cy(value) { - return this.attr('cy', value) - } - - color(value) { - return this.attr('fill', value) - } - - radius(value) { - return this.attr('r', value) - } - - title(value) { - this.item.selectAll('*').remove() - if (value && value !== '') { - this.item.append('title').text(value) - } - return this - } - - transition() { - return new DataPointCirclePlotter(this._svg, this.item.transition()) - } - - duration(value) { - return new DataPointCirclePlotter(this._svg, this.item.duration(value)) - } - - remove() { - return this.item.remove() - } -} - -export class DataPointStarPlotter { - constructor(svg, item, polygon) { - this._svg = svg - this._c = [0, 0] - this._r = 5 - if (item) { - this.g = item - this.polygon = polygon - } else { - this.g = this._svg.append('g') - this.polygon = this.g.append('polygon') - this.polygon.attr('points', this._path()).attr('stroke', 'black') - } - } - - _path() { - return [ - [-Math.sin((Math.PI * 2) / 5), -Math.cos((Math.PI * 2) / 5)], - [-Math.sin(Math.PI / 5) / 2, -Math.cos(Math.PI / 5) / 2], - [0, -1], - [Math.sin(Math.PI / 5) / 2, -Math.cos(Math.PI / 5) / 2], - [Math.sin((Math.PI * 2) / 5), -Math.cos((Math.PI * 2) / 5)], - [Math.sin((Math.PI * 3) / 5) / 2, -Math.cos((Math.PI * 3) / 5) / 2], - [Math.sin((Math.PI * 4) / 5), -Math.cos((Math.PI * 4) / 5)], - [0, 1 / 2], - [-Math.sin((Math.PI * 4) / 5), -Math.cos((Math.PI * 4) / 5)], - [-Math.sin((Math.PI * 3) / 5) / 2, -Math.cos((Math.PI * 3) / 5) / 2], - ].reduce((acc, v) => acc + v[0] * this._r + ',' + v[1] * this._r + ' ', '') - } - - cx(value) { - this._c[0] = value || this._c[0] - return value !== undefined - ? this.g.attr('transform', 'translate(' + this._c[0] + ', ' + this._c[1] + ')') && this - : this._c[0] - } - - cy(value) { - this._c[1] = value || this._c[1] - return value !== undefined - ? this.g.attr('transform', 'translate(' + this._c[0] + ', ' + this._c[1] + ')') && this - : this._c[1] - } - - color(value) { - return value !== undefined ? this.polygon.attr('fill', value) && this : this.polygon.attr('fill') - } - - radius(value) { - this._r = value || this._r - return value !== undefined ? this.polygon.attr('points', this._path()) && this : this._r - } - - title(value) { - this.polygon.selectAll('*').remove() - if (value && value !== '') { - this.polygon.append('title').text(value) - } - return this - } - - transition() { - return new DataPointStarPlotter(this._svg, this.g.transition(), this.polygon.transition()) - } - - duration(value) { - return new DataPointStarPlotter(this._svg, this.g.duration(value), this.polygon.duration(value)) - } - - remove() { - return this.g.remove() - } -} - -class DataVector { - constructor(value) { - this.value = value instanceof DataVector ? value.value : value - } - - get length() { - return Math.sqrt(this.value.reduce((acc, v) => acc + v * v, 0)) - } - - map(func) { - return new DataVector(this.value.map(func)) - } - - reduce(func, init) { - return this.value.reduce(func, init) - } - - add(p) { - return this.map((v, i) => v + p.value[i]) - } - - sub(p) { - return this.map((v, i) => v - p.value[i]) - } - - mult(n) { - return this.map(v => v * n) - } - - div(n) { - return this.map(v => v / n) - } - - dot(p) { - return this.value.reduce((acc, v, i) => acc + v * p.value[i], 0) - } - - distance(p) { - return Math.sqrt(this.value.reduce((acc, v, i) => acc + (v - p.value[i]) ** 2, 0)) - } - - angleCos(p) { - return this.dot(p) / (this.length * p.length) - } - - equals(p) { - return this.value.every((v, i) => v === p.value[i]) - } -} - +const rgb = (r, g, b) => ({ + r, + g, + b, + toString() { + return `rgb(${r}, ${g}, ${b})` + }, +}) const categoryColors = { - '-2': d3.rgb(255, 0, 0), - '-1': d3.rgb(255, 255, 255), - 0: d3.rgb(0, 0, 0), + '-2': rgb(255, 0, 0), + '-1': rgb(255, 255, 255), + 0: rgb(0, 0, 0), } export const specialCategory = { @@ -213,7 +50,7 @@ export const getCategoryColor = function (i) { let clr_l = getCategoryColor(Math.floor(i)) let clr_h = getCategoryColor(Math.ceil(i)) let r = i - Math.floor(i) - return d3.rgb( + return rgb( Math.round(clr_l.r + (clr_h.r - clr_l.r) * r), Math.round(clr_l.g + (clr_h.g - clr_l.g) * r), Math.round(clr_l.b + (clr_h.b - clr_l.b) * r) @@ -242,498 +79,10 @@ export const getCategoryColor = function (i) { } } if (Math.random() - cnt / 200 < Math.sqrt(min_dis / 3)) { - categoryColors[i] = d3.rgb(Math.floor(d[0] * 256), Math.floor(d[1] * 256), Math.floor(d[2] * 256)) + categoryColors[i] = rgb(Math.floor(d[0] * 256), Math.floor(d[1] * 256), Math.floor(d[2] * 256)) break } } } return categoryColors[i] } - -export class DataPoint { - constructor(svg, position = [0, 0], category = 0) { - this.svg = d3.select(svg) - this.vector = new DataVector(position) - this._color = getCategoryColor(category) - this._category = category - this._radius = 5 - this._plotter = new DataPointCirclePlotter(this.svg) - this._binds = [] - this.display() - } - - display() { - this._plotter - .cx('' + this.vector.value[0]) - .cy('' + this.vector.value[1]) - .radius(this._radius) - .color(this._color) - this._binds.forEach(e => e.display()) - } - - get item() { - return this._plotter.item - } - - get at() { - return this.vector.value - } - set at(position) { - this.vector = new DataVector(position) - this.display() - } - get color() { - return this._color - } - get category() { - return this._category - } - set category(category) { - this._category = category - this._color = getCategoryColor(category) - this.display() - } - get radius() { - return this._radius - } - set radius(radius) { - this._radius = radius - this.display() - } - set title(value) { - this._plotter.title(value) - } - - plotter(plt) { - this._plotter.remove() - this._plotter = new plt(this.svg) - this.display() - } - - remove() { - this._plotter.remove() - this._binds.forEach(e => e.remove()) - } - - move(to, duration = 1000) { - this.vector = new DataVector(to) - this._plotter.transition().duration(duration).cx(this.vector.value[0]).cy(this.vector.value[1]) - this._binds.forEach(e => e.move(duration)) - } - - distance(p) { - return this.vector.distance(p.vector) - } - - bind(e) { - this._binds.push(e) - } - - removeBind(e) { - this._binds = this._binds.filter(b => b !== e) - } - - static sum(arr) { - return arr.length === 0 ? [] : arr.slice(1).reduce((acc, v) => acc.add(v.vector), arr[0].vector) - } - static mean(arr) { - return arr.length === 0 ? [] : DataPoint.sum(arr).div(arr.length) - } -} - -export class DataCircle { - constructor(svg, at) { - this._svg = d3.select(svg) - this.item = this._svg.append('circle').attr('fill-opacity', 0) - this._at = at - this._color = null - this._width = 4 - at.bind(this) - this.display() - } - - get color() { - return this._color || this._at.color - } - set color(value) { - this._color = value - this.display() - } - - set title(value) { - this.item.selectAll('*').remove() - if (value && value.length > 0) { - this.item.append('title').text(value) - } - } - - display() { - this.item - .attr('cx', this._at.at[0]) - .attr('cy', this._at.at[1]) - .attr('stroke', this.color) - .attr('stroke-width', this._width) - .attr('r', this._at._radius) - } - - move(duration = 1000) { - this.item.transition().duration(duration).attr('cx', this._at.at[0]).attr('cy', this._at.at[1]) - } - - remove() { - this.item.remove() - this._at.removeBind(this) - } -} - -export class DataLine { - constructor(svg, from, to) { - this._svg = d3.select(svg) - this.item = this._svg.append('line') - this._from = from - this._to = to - this._remove_listener = null - from && from.bind(this) - to && to.bind(this) - this.display() - } - - set from(value) { - this._from && this._from.removeBind(this) - this._from = value - this._from.bind(this) - } - - set to(value) { - this._to && this._to.removeBind(this) - this._to = value - this._to.bind(this) - } - - display() { - if (!this._from || !this._to) return - this.item - .attr('x1', this._from.at[0]) - .attr('y1', this._from.at[1]) - .attr('x2', this._to.at[0]) - .attr('y2', this._to.at[1]) - .attr('stroke', this._from.color) - } - - move(duration = 1000) { - if (!this._from || !this._to) return - this.item - .transition() - .duration(duration) - .attr('x1', this._from.at[0]) - .attr('y1', this._from.at[1]) - .attr('x2', this._to.at[0]) - .attr('y2', this._to.at[1]) - } - - remove() { - this.item.remove() - this._from && this._from.removeBind(this) - this._from = null - this._to && this._to.removeBind(this) - this._to = null - this._remove_listener && this._remove_listener(this) - } - - setRemoveListener(cb) { - this._remove_listener = cb - } -} - -export class DataConvexHull { - constructor(svg, points) { - this._svg = svg - this.item = svg.append('polygon') - this._points = points - this._color = null - this.display() - } - - get color() { - return this._color || this._points[0].color - } - set color(value) { - this._color = value - this.display() - } - - _argmin(arr, key) { - if (arr.length === 0) { - return -1 - } - arr = key ? arr.map(key) : arr - return arr.indexOf(Math.min(...arr)) - } - - _convexPoints() { - if (this._points.length <= 3) { - return this._points - } - let cp = [].concat(this._points) - let basei = this._argmin(cp, p => p.at[1]) - const base = cp.splice(basei, 1)[0] - cp.sort((a, b) => { - let dva = a.vector.sub(base.vector) - let dvb = b.vector.sub(base.vector) - return dva.value[0] / dva.length - dvb.value[0] / dvb.length - }) - let outers = [base] - for (let k = 0; k < cp.length; k++) { - while (outers.length >= 3) { - let n = outers.length - const v = outers[n - 1].vector.sub(outers[n - 2].vector).value - const newv = cp[k].vector.sub(outers[n - 2].vector).value - const basev = base.vector.sub(outers[n - 2].vector).value - if ((v[0] * basev[1] - v[1] * basev[0]) * (v[0] * newv[1] - v[1] * newv[0]) > 0) { - break - } - outers.pop() - } - outers.push(cp[k]) - } - return outers - } - - display() { - let points = this._convexPoints().reduce((acc, p) => acc + p.at[0] + ',' + p.at[1] + ' ', '') - this.item.attr('points', points).attr('stroke', this.color).attr('fill', this.color).attr('opacity', 0.5) - } - - remove() { - this.item.remove() - } -} - -class DataMap { - constructor() { - this._data = [] - this._size = [0, 0] - } - - get rows() { - return this._size[0] - } - - get cols() { - return this._size[1] - } - - at(x, y) { - return x < 0 || !this._data[x] || y < 0 ? undefined : this._data[x][y] - } - - set(x, y, value) { - if (!this._data[x]) this._data[x] = [] - this._data[x][y] = value - this._size[0] = Math.max(this._size[0], x + 1) - this._size[1] = Math.max(this._size[1], y + 1) - } -} - -export class DataHulls { - constructor(svg, categories, tileSize, use_canvas = false, mousemove = null) { - this._svg = d3.select(svg) - this._categories = categories - this._tileSize = tileSize - if (!Array.isArray(this._tileSize)) { - this._tileSize = [this._tileSize, this._tileSize] - } - this._use_canvas = use_canvas - this._mousemove = mousemove - this.display() - } - - display() { - if (this._use_canvas) { - const root_svg = document.querySelector('#plot-area svg') - const canvas = document.createElement('canvas') - canvas.width = root_svg.getBoundingClientRect().width - canvas.height = root_svg.getBoundingClientRect().height - let ctx = canvas.getContext('2d') - for (let i = 0; i < this._categories.length; i++) { - for (let j = 0; j < this._categories[i].length; j++) { - ctx.fillStyle = getCategoryColor(this._categories[i][j]) - ctx.fillRect( - Math.round(j * this._tileSize[0]), - Math.round(i * this._tileSize[1]), - Math.ceil(this._tileSize[0]), - Math.ceil(this._tileSize[1]) - ) - } - } - let o = this - this._svg - .append('image') - .attr('x', 0) - .attr('y', 0) - .attr('width', canvas.width) - .attr('height', canvas.height) - .attr('xlink:href', canvas.toDataURL()) - .on('mousemove', e => { - const mousePos = d3.pointer(e) - this._mousemove && - this._mousemove( - o._categories[Math.round(mousePos[1] / o._tileSize)][Math.round(mousePos[0] / o._tileSize)] - ) - }) - return - } - let categories = new DataMap() - for (let i = 0; i < this._categories.length; i++) { - for (let j = 0; j < this._categories[i].length; j++) { - if (this._categories[i][j] === null) { - categories.set(i, j, null) - } else { - categories.set(i, j, Math.round(this._categories[i][j])) - } - } - } - const invalid = [] - for (let i = 0; i < categories.rows; i++) { - for (let j = 0; j < categories.cols; j++) { - if (categories.at(i, j) <= specialCategory.never) { - continue - } - let targetCategory = categories.at(i, j) - let targets = new DataMap() - let hulls = new DataMap() - let checkTargets = [[i, j]] - let ignore = false - while (checkTargets.length > 0) { - let [y, x] = checkTargets.pop() - if (categories.at(y, x) === targetCategory) { - targets.set(y, x, 1) - categories.set(y, x, specialCategory.never) - checkTargets.push([y - 1, x]) - checkTargets.push([y + 1, x]) - checkTargets.push([y, x - 1]) - checkTargets.push([y, x + 1]) - hulls.set( - y, - x, - (targets.at(y - 1, x) !== 1 && categories.at(y - 1, x) !== targetCategory) || - (targets.at(y + 1, x) !== 1 && categories.at(y + 1, x) !== targetCategory) || - (targets.at(y, x - 1) !== 1 && categories.at(y, x - 1) !== targetCategory) || - (targets.at(y, x + 1) !== 1 && categories.at(y, x + 1) !== targetCategory) - ) - } else if (categories.at(y, x) === undefined && targetCategory === null) { - ignore = true - } - } - if (ignore) continue - let hullPoints = [[i, j]] - let y = i, - x = j + 1 - const max_count = categories.rows * categories.cols - let count = 0 - let ori = 'r' - while (y != i || x != j) { - let lt = targets.at(y - 1, x - 1) - let rt = targets.at(y - 1, x) - let lb = targets.at(y, x - 1) - let rb = targets.at(y, x) - if (rt && lt && lb && rb) { - invalid.push([y, x]) - break - } else if (rt && lt && lb) { - hullPoints.push([y, x]) - ori = 'b' - } else if (lt && lb && rb) { - hullPoints.push([y, x]) - ori = 'r' - } else if (lb && rb && rt) { - hullPoints.push([y, x]) - ori = 't' - } else if (rb && rt && lt) { - hullPoints.push([y, x]) - ori = 'l' - } else if (rt && lt) { - ori = 'l' - } else if (lt && lb) { - ori = 'b' - } else if (lb && rb) { - ori = 'r' - } else if (rb && rt) { - ori = 't' - } else if (rt && lb) { - hullPoints.push([y, x]) - if (ori === 'l') { - ori = 't' - } else if (ori === 'r') { - ori = 'b' - } else { - invalid.push([y, x]) - } - } else if (lt && rb) { - hullPoints.push([y, x]) - if (ori === 't') { - ori = 'r' - } else if (ori === 'b') { - ori = 'l' - } else { - invalid.push([y, x]) - } - } else if (rt) { - hullPoints.push([y, x]) - ori = 't' - } else if (lt) { - hullPoints.push([y, x]) - ori = 'l' - } else if (lb) { - hullPoints.push([y, x]) - ori = 'b' - } else if (rb) { - hullPoints.push([y, x]) - ori = 'r' - } else { - invalid.push([y, x]) - break - } - if (ori === 'r') { - x += 1 - } else if (ori === 'l') { - x -= 1 - } else if (ori === 'b') { - y += 1 - } else if (ori === 't') { - y -= 1 - } - count += 1 - if (count >= max_count) { - invalid.push([y, x]) - break - } - } - this._svg - .append('polygon') - .attr( - 'points', - hullPoints.reduce( - (acc, p) => acc + p[1] * this._tileSize[0] + ',' + p[0] * this._tileSize[1] + ' ', - '' - ) - ) - .attr('fill', targetCategory === null ? 'white' : getCategoryColor(targetCategory)) - } - } - - if (invalid.length > 0) { - let s = '' - if (invalid.length > 100) { - s = '[' - s += invalid.slice(0, 50).map(JSON.stringify).join(',') - s += ',...,' - s += invalid.slice(-50).map(JSON.stringify).join(',') - s += ']' - } else { - s = JSON.stringify(invalid) - } - console.log('invalid loop condition at ' + s) - } - } -} diff --git a/js/view/agglomerative.js b/js/view/agglomerative.js index 67ef799f..a19c548a 100644 --- a/js/view/agglomerative.js +++ b/js/view/agglomerative.js @@ -8,7 +8,8 @@ import { MedianAgglomerativeClustering, } from '../../lib/model/agglomerative.js' import Controller from '../controller.js' -import { getCategoryColor, DataConvexHull } from '../utils.js' +import { getCategoryColor } from '../utils.js' +import { DataConvexHull } from '../renderer/util/figure.js' const argmin = function (arr, key) { if (arr.length === 0) { @@ -32,7 +33,7 @@ export default function (platform) { platform.setting.terminate = () => { document.querySelector('svg .grouping').remove() } - const svg = d3.select(platform.svg) + const svg = platform.svg const line = p => { let s = '' for (let i = 0; i < p.length; i++) { @@ -45,7 +46,9 @@ export default function (platform) { let clusterClass = null let clusterInstance = null let clusterPlot = null - svg.insert('g', ':first-child').attr('class', 'grouping') + const grouping = document.createElementNS('http://www.w3.org/2000/svg', 'g') + svg.insertBefore(grouping, svg.firstChild) + grouping.classList.add('grouping') const plotLink = getLinks => { let lines = [] @@ -81,17 +84,16 @@ export default function (platform) { category += h.size }) platform.trainResult = preds - svg.selectAll('.grouping path').remove() - svg.select('.grouping') - .selectAll('path') - .data(lines) - .enter() - .append('path') - .attr('d', d => line(d.path)) - .attr('stroke', d => d.color) + grouping.querySelectorAll('path').forEach(elm => elm.remove()) + for (const l of lines) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', line(l.path)) + path.setAttribute('stroke', l.color) + grouping.append(path) + } } const plotConvex = function () { - svg.selectAll('.grouping polygon').remove() + grouping.querySelectorAll('polygon').forEach(elm => elm.remove()) const clusters = clusternumber.value let category = 1 const preds = [] @@ -110,7 +112,7 @@ export default function (platform) { } } h.poly = new DataConvexHull( - svg.select('.grouping'), + grouping, h.leafs.map(v => platform._renderer[0].points[v.index]) ) h.poly.color = getCategoryColor(category) @@ -199,8 +201,8 @@ export default function (platform) { clusternumber.element.max = platform.datas.length clusternumber.element.value = 10 clusternumber.element.disabled = false - svg.selectAll('path').remove() - svg.selectAll('.grouping *').remove() + svg.querySelectorAll('path').forEach(elm => elm.remove()) + grouping.replaceChildren() clusterPlot() } }) diff --git a/js/view/dbscan.js b/js/view/dbscan.js index 1ea3bf2a..2d8648eb 100644 --- a/js/view/dbscan.js +++ b/js/view/dbscan.js @@ -9,54 +9,53 @@ export default function (platform) { url: 'https://en.wikipedia.org/wiki/DBSCAN', } const controller = new Controller(platform) - const svg = d3.select(platform.svg) - svg.insert('g', ':first-child').attr('class', 'range').attr('opacity', 0.4) + const svg = platform.svg + const range = document.createElementNS('http://www.w3.org/2000/svg', 'g') + svg.insertBefore(range, svg.firstChild) + range.classList.add('range') + range.setAttribute('opacity', 0.4) const fitModel = () => { - svg.selectAll('.range *').remove() + range.replaceChildren() const model = new DBSCAN(eps.value, minpts.value, metric.value) const pred = model.predict(platform.trainInput) platform.trainResult = pred.map(v => v + 1) clusters.value = new Set(pred).size const scale = platform._renderer[0].scale[0] + const datas = platform.trainInput if (metric.value === 'euclid') { - svg.select('.range') - .selectAll('circle') - .data(platform.trainInput) - .enter() - .append('circle') - .attr('cx', c => c[0] * scale) - .attr('cy', c => c[1] * scale) - .attr('r', eps.value * scale) - .attr('fill-opacity', 0) - .attr('stroke', (c, i) => getCategoryColor(pred[i] + 1)) + for (let i = 0; i < datas.length; i++) { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + circle.setAttribute('cx', datas[i][0] * scale) + circle.setAttribute('cy', datas[i][1] * scale) + circle.setAttribute('r', eps.value * scale) + circle.setAttribute('fill-opacity', 0) + circle.setAttribute('stroke', getCategoryColor(pred[i] + 1)) + range.append(circle) + } } else if (metric.value === 'manhattan') { - svg.select('.range') - .selectAll('polygon') - .data(platform.trainInput) - .enter() - .append('polygon') - .attr('points', c => { - const x0 = c[0] * scale - const y0 = c[1] * scale - const d = eps.value * scale - return `${x0 - d},${y0} ${x0},${y0 - d} ${x0 + d},${y0} ${x0},${y0 + d}` - }) - .attr('fill-opacity', 0) - .attr('stroke', (c, i) => getCategoryColor(pred[i] + 1)) + for (let i = 0; i < datas.length; i++) { + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + const x0 = datas[i][0] * scale + const y0 = datas[i][1] * scale + const d = eps.value * scale + polygon.setAttribute('points', `${x0 - d},${y0} ${x0},${y0 - d} ${x0 + d},${y0} ${x0},${y0 + d}`) + polygon.setAttribute('fill-opacity', 0) + polygon.setAttribute('stroke', getCategoryColor(pred[i] + 1)) + range.append(polygon) + } } else if (metric.value === 'chebyshev') { - svg.select('.range') - .selectAll('rect') - .data(platform.trainInput) - .enter() - .append('rect') - .attr('x', c => (c[0] - eps.value) * scale) - .attr('y', c => (c[1] - eps.value) * scale) - .attr('width', eps.value * 2 * scale) - .attr('height', eps.value * 2 * scale) - .attr('fill-opacity', 0) - .attr('stroke', (c, i) => getCategoryColor(pred[i] + 1)) + for (let i = 0; i < datas.length; i++) { + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') + rect.setAttribute('x', (datas[i][0] - eps.value) * scale) + rect.setAttribute('y', (datas[i][1] - eps.value) * scale) + rect.setAttribute('width', eps.value * 2 * scale) + rect.setAttribute('height', eps.value * 2 * scale) + rect.setAttribute('fill-opacity', 0) + rect.setAttribute('stroke', getCategoryColor(pred[i] + 1)) + range.append(rect) + } } } @@ -68,6 +67,6 @@ export default function (platform) { controller.input.button('Fit').on('click', fitModel) const clusters = controller.text({ label: ' Clusters: ' }) platform.setting.terminate = () => { - svg.select('.range').remove() + range.remove() } } diff --git a/js/view/decision_tree.js b/js/view/decision_tree.js index 529ccb3d..3ef0f7e3 100644 --- a/js/view/decision_tree.js +++ b/js/view/decision_tree.js @@ -8,24 +8,27 @@ class DecisionTreePlotter { constructor(platform) { this._platform = platform this._mode = platform.task - this._svg = d3.select(platform.svg) + this._svg = platform.svg this._r = null this._lineEdge = [] } remove() { - this._svg.select('.separation').remove() + this._svg.querySelector('.separation')?.remove() } plot(tree) { - this._svg.select('.separation').remove() + this._svg.querySelector('.separation')?.remove() if (this._platform.datas.length === 0) { return } + this._r = document.createElementNS('http://www.w3.org/2000/svg', 'g') + this._r.classList.add('separation') if (this._platform.datas.dimension === 1) { - this._r = this._svg.insert('g').attr('class', 'separation') + this._svg.append(this._r) } else { - this._r = this._svg.insert('g', ':first-child').attr('class', 'separation').attr('opacity', 0.5) + this._svg.insertBefore(this._r, this._svg.firstChild) + this._r.setAttribute('opacity', 0.5) } this._lineEdge = [] this._dispRange(tree._tree) @@ -37,14 +40,17 @@ class DecisionTreePlotter { } return s } - this._r.append('path').attr('stroke', 'red').attr('fill-opacity', 0).attr('d', line(this._lineEdge)) + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('stroke', 'red') + path.setAttribute('fill-opacity', 0) + path.setAttribute('d', line(this._lineEdge)) + this._r.append(path) } } _dispRange(root, r) { r = r || this._platform.datas.domain if (root.children.length === 0) { - const sep = this._r let max_cls = 0, max_v = 0 if (this._mode === 'CF') { @@ -65,12 +71,13 @@ class DecisionTreePlotter { } else { const p1 = this._platform._renderer[0].toPoint([r[0][0], r[1][0]]) const p2 = this._platform._renderer[0].toPoint([r[0][1], r[1][1]]) - sep.append('rect') - .attr('x', p1[0]) - .attr('y', p1[1]) - .attr('width', p2[0] - p1[0]) - .attr('height', p2[1] - p1[1]) - .attr('fill', getCategoryColor(max_cls)) + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') + rect.setAttribute('x', p1[0]) + rect.setAttribute('y', p1[1]) + rect.setAttribute('width', p2[0] - p1[0]) + rect.setAttribute('height', p2[1] - p1[1]) + rect.setAttribute('fill', getCategoryColor(max_cls)) + this._r.append(rect) } } else { root.children.forEach((n, i) => { diff --git a/js/view/gmm.js b/js/view/gmm.js index 83e749cf..9f46ffe1 100644 --- a/js/view/gmm.js +++ b/js/view/gmm.js @@ -7,7 +7,8 @@ import { specialCategory, getCategoryColor } from '../utils.js' class GMMPlotter { // see http://d.hatena.ne.jp/natsutan/20110421/1303344155 constructor(svg, model, grayscale = false) { - this._r = d3.select(svg).append('g').attr('class', 'centroids2') + this._r = document.createElementNS('http://www.w3.org/2000/svg', 'g') + svg.append(this._r) this._model = model this._size = 0 this._circle = [] @@ -33,21 +34,22 @@ class GMMPlotter { t = 0 } - ell.attr('rx', c * Math.sqrt(su2) * 1000) - .attr('ry', c * Math.sqrt(sv2) * 1000) - .attr('transform', 'translate(' + cn[0] * 1000 + ',' + cn[1] * 1000 + ') ' + 'rotate(' + t + ')') + ell.setAttribute('rx', c * Math.sqrt(su2) * 1000) + ell.setAttribute('ry', c * Math.sqrt(sv2) * 1000) + ell.setAttribute('transform', 'translate(' + cn[0] * 1000 + ',' + cn[1] * 1000 + ') ' + 'rotate(' + t + ')') } add(category) { this._size++ - const cecl = this._r - .append('ellipse') - .attr('cx', 0) - .attr('cy', 0) - .attr('stroke', this._grayscale ? 'gray' : getCategoryColor(category || this._size)) - .attr('stroke-width', 2) - .attr('fill-opacity', 0) + const cecl = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse') + cecl.setAttribute('cx', 0) + cecl.setAttribute('cy', 0) + cecl.setAttribute('stroke', this._grayscale ? 'gray' : getCategoryColor(category || this._size)) + cecl.setAttribute('stroke-width', 2) + cecl.setAttribute('fill-opacity', 0) + cecl.style.transitionDuration = this._duration + 'ms' + this._r.append(cecl) this._set_el_attr(cecl, this._size - 1) this._circle.push(cecl) } @@ -60,7 +62,7 @@ class GMMPlotter { move() { this._circle.forEach((ecl, i) => { - this._set_el_attr(ecl.transition().duration(this._duration), i) + this._set_el_attr(ecl, i) }) } } diff --git a/js/view/mean_shift.js b/js/view/mean_shift.js index 743dd278..704fb924 100644 --- a/js/view/mean_shift.js +++ b/js/view/mean_shift.js @@ -8,8 +8,10 @@ export default function (platform) { title: 'Mean shift (Wikipedia)', url: 'https://en.wikipedia.org/wiki/Mean_shift', } - const svg = d3.select(platform.svg) - const csvg = svg.insert('g', ':first-child').attr('class', 'centroids').attr('opacity', 0.8) + const csvg = document.createElementNS('http://www.w3.org/2000/svg', 'g') + platform.svg.insertBefore(csvg, platform.svg.firstChild) + csvg.classList.add('centroids') + csvg.setAttribute('opacity', 0.8) const controller = new Controller(platform) let c = [] @@ -20,10 +22,9 @@ export default function (platform) { const pred = model.predict(threshold.value) platform.trainResult = pred.map(v => v + 1) for (let i = 0; i < c.length; i++) { - c[i] - .attr('stroke', getCategoryColor(pred[i] + 1)) - .attr('cx', model._centroids[i][0] * scale) - .attr('cy', model._centroids[i][1] * scale) + c[i].setAttribute('stroke', getCategoryColor(pred[i] + 1)) + c[i].setAttribute('cx', model._centroids[i][0] * scale) + c[i].setAttribute('cy', model._centroids[i][1] * scale) } } @@ -46,14 +47,15 @@ export default function (platform) { if (platform.task !== 'SG' && scale > 0) { c.forEach(c => c.remove()) c = platform._renderer[0].points.map(p => { - return csvg - .append('circle') - .attr('cx', p.at[0] * scale) - .attr('cy', p.at[1] * scale) - .attr('r', model.h * scale) - .attr('stroke', 'black') - .attr('fill-opacity', 0) - .attr('stroke-opacity', 0.5) + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + circle.setAttribute('cx', p.at[0] * scale) + circle.setAttribute('cy', p.at[1] * scale) + circle.setAttribute('r', model.h * scale) + circle.setAttribute('stroke', 'black') + circle.setAttribute('fill-opacity', 0) + circle.setAttribute('stroke-opacity', 0.5) + csvg.append(circle) + return circle }) } plot() diff --git a/js/view/pca.js b/js/view/pca.js index 3dea0cc0..85133244 100644 --- a/js/view/pca.js +++ b/js/view/pca.js @@ -1,24 +1,25 @@ import { PCA, DualPCA, KernelPCA, AnomalyPCA } from '../../lib/model/pca.js' +import Controller from '../controller.js' -var dispPCA = function (elm, platform) { +export default function (platform) { + platform.setting.ml.usage = 'Click and add data point. Next, click "Fit" button.' + const controller = new Controller(platform) const fitModel = () => { if (platform.task === 'DR') { const dim = platform.dimension - const kernel = elm.select('[name=kernel]').property('value') - const type = elm.select('[name=type]').property('value') let model - if (type === '') { + if (type.value === '') { model = new PCA() - } else if (type === 'dual') { + } else if (type.value === 'dual') { model = new DualPCA() } else { const args = [] - if (kernel === 'polynomial') { - args.push(+elm.select('[name=poly_d]').property('value')) - } else if (kernel === 'gaussian') { - args.push(+elm.select('[name=sigma]').property('value')) + if (kernel.value === 'polynomial') { + args.push(poly_d.value) + } else if (kernel.value === 'gaussian') { + args.push(sigma.value) } - model = new KernelPCA(kernel, args) + model = new KernelPCA(kernel.value, args) } model.fit(platform.trainInput) const y = model.predict(platform.trainInput, dim) @@ -26,7 +27,7 @@ var dispPCA = function (elm, platform) { } else { const model = new AnomalyPCA() model.fit(platform.trainInput) - const th = +elm.select('[name=threshold]').property('value') + const th = threshold.value const y = model.predict(platform.trainInput) platform.trainResult = y.map(v => v > th) const p = model.predict(platform.testInput(10)) @@ -34,80 +35,37 @@ var dispPCA = function (elm, platform) { } } + let type = null if (platform.task !== 'AD') { - elm.append('select') - .attr('name', 'type') - .on('change', function () { - const slct = d3.select(this) - if (slct.property('value') === 'kernel') { - kernelElm.style('display', 'inline-block') - } else { - kernelElm.style('display', 'none') - } - }) - .selectAll('option') - .data(['', 'dual', 'kernel']) - .enter() - .append('option') - .attr('value', d => d) - .text(d => d) - } - const kernelElm = elm.append('span').style('display', 'none') - kernelElm - .append('select') - .attr('name', 'kernel') - .on('change', function () { - const slct = d3.select(this) - poly_dimension.style('display', 'none') - gauss_sigma.style('display', 'none') - if (slct.property('value') === 'polynomial') { - poly_dimension.style('display', 'inline-block') - } else if (slct.property('value') === 'gaussian') { - gauss_sigma.style('display', 'inline-block') + type = controller.select(['', 'dual', 'kernel']).on('change', () => { + if (type.value === 'kernel') { + kernelElm.element.style.display = 'inline-block' + } else { + kernelElm.element.style.display = 'none' } }) - .selectAll('option') - .data(['gaussian', 'polynomial']) - .enter() - .append('option') - .attr('value', d => d) - .text(d => d) - const poly_dimension = kernelElm.append('span').style('display', 'none') - poly_dimension - .append('span') - .text(' d = ') - .append('input') - .attr('type', 'number') - .attr('name', 'poly_d') - .attr('value', 2) - .attr('min', 1) - .attr('max', 10) - const gauss_sigma = kernelElm.append('span') - gauss_sigma - .append('span') - .text(' sigma = ') - .append('input') - .attr('type', 'number') - .attr('name', 'sigma') - .attr('value', 1) - .attr('min', 0) - .attr('max', 10) - .attr('step', 0.1) + } + const kernelElm = controller.span() + kernelElm.element.style.display = 'none' + const kernel = kernelElm.select(['gaussian', 'polynomial']).on('change', function () { + poly_dimension.element.style.display = 'none' + gauss_sigma.element.style.display = 'none' + if (kernel.value === 'polynomial') { + poly_dimension.element.style.display = 'inline-block' + } else if (kernel.value === 'gaussian') { + gauss_sigma.element.style.display = 'inline-block' + } + }) + const poly_dimension = kernelElm.span() + poly_dimension.element.style.display = 'none' + const poly_d = poly_dimension.input.number({ label: ' d = ', value: 2, min: 1, max: 10 }) + const gauss_sigma = kernelElm.span() + const sigma = gauss_sigma.input.number({ label: ' sigma = ', value: 1, min: 0, max: 10, step: 0.1 }) + let threshold = null if (platform.task === 'AD') { - elm.append('span').text(' threshold = ') - elm.append('input') - .attr('type', 'number') - .attr('name', 'threshold') - .attr('value', 0.1) - .attr('min', 0) - .attr('max', 10) - .attr('step', 0.01) + threshold = controller.input + .number({ label: ' threshold = ', value: 0.1, min: 0, max: 10, step: 0.01 }) .on('change', fitModel) } - elm.append('input').attr('type', 'button').attr('value', 'Fit').on('click', fitModel) -} - -export default function (platform) { - platform.setting.ml.usage = 'Click and add data point. Next, click "Fit" button.' - dispPCA(platform.setting.ml.configElement, platform) + controller.input.button('Fit').on('click', fitModel) } diff --git a/js/view/vbgmm.js b/js/view/vbgmm.js index 34a79467..1e7877eb 100644 --- a/js/view/vbgmm.js +++ b/js/view/vbgmm.js @@ -4,7 +4,8 @@ import { getCategoryColor } from '../utils.js' class VBGMMPlotter { constructor(svg, model) { - this._r = d3.select(svg).append('g').attr('class', 'centroids2') + this._r = document.createElementNS('http://www.w3.org/2000/svg', 'g') + svg.append(this._r) this._model = model this._size = model._k this._circle = [] @@ -22,13 +23,14 @@ class VBGMMPlotter { } add(category) { - let cecl = this._r - .append('ellipse') - .attr('cx', 0) - .attr('cy', 0) - .attr('stroke', getCategoryColor(category)) - .attr('stroke-width', 2) - .attr('fill-opacity', 0) + const cecl = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse') + cecl.setAttribute('cx', 0) + cecl.setAttribute('cy', 0) + cecl.setAttribute('stroke', getCategoryColor(category)) + cecl.setAttribute('stroke-width', 2) + cecl.setAttribute('fill-opacity', 0) + cecl.style.transitionDuration = this._duration + 'ms' + this._r.append(cecl) this._set_el_attr(cecl, this._size - 1) this._circle.push(cecl) this._rm.push(false) @@ -45,12 +47,12 @@ class VBGMMPlotter { t = 0 } - ell.attr('rx', c * Math.sqrt(su2) * this._scale) - .attr('ry', c * Math.sqrt(sv2) * this._scale) - .attr( - 'transform', - 'translate(' + cn[0] * this._scale + ',' + cn[1] * this._scale + ') ' + 'rotate(' + t + ')' - ) + ell.setAttribute('rx', c * Math.sqrt(su2) * this._scale) + ell.setAttribute('ry', c * Math.sqrt(sv2) * this._scale) + ell.setAttribute( + 'transform', + 'translate(' + cn[0] * this._scale + ',' + cn[1] * this._scale + ') ' + 'rotate(' + t + ')' + ) } move() { @@ -64,7 +66,7 @@ class VBGMMPlotter { } this._circle.forEach((ecl, i) => { if (this._rm[i]) return - this._set_el_attr(ecl.transition().duration(this._duration), i) + this._set_el_attr(ecl, i) }) } } diff --git a/tests/gui/view/pca.test.js b/tests/gui/view/pca.test.js new file mode 100644 index 00000000..9403c204 --- /dev/null +++ b/tests/gui/view/pca.test.js @@ -0,0 +1,44 @@ +import { getPage } from '../helper/browser' + +describe('dimensionality reduction', () => { + /** @type {Awaited>} */ + let page + beforeEach(async () => { + page = await getPage() + }, 10000) + + afterEach(async () => { + await page?.close() + }) + + test('initialize', async () => { + const taskSelectBox = await page.waitForSelector('#ml_selector dl:first-child dd:nth-child(5) select') + await taskSelectBox.selectOption('DR') + const modelSelectBox = await page.waitForSelector('#ml_selector .model_selection #mlDisp') + await modelSelectBox.selectOption('pca') + const methodMenu = await page.waitForSelector('#ml_selector #method_menu') + const buttons = await methodMenu.waitForSelector('.buttons') + + const methods = await buttons.waitForSelector('select:nth-of-type(1)') + await expect((await methods.getProperty('value')).jsonValue()).resolves.toBe('') + const fitButton = await buttons.waitForSelector('input[value=Fit]') + expect(fitButton).toBeDefined() + }, 10000) + + test('learn', async () => { + const taskSelectBox = await page.waitForSelector('#ml_selector dl:first-child dd:nth-child(5) select') + await taskSelectBox.selectOption('DR') + const modelSelectBox = await page.waitForSelector('#ml_selector .model_selection #mlDisp') + await modelSelectBox.selectOption('pca') + const methodMenu = await page.waitForSelector('#ml_selector #method_menu') + const buttons = await methodMenu.waitForSelector('.buttons') + + const fitButton = await buttons.waitForSelector('input[value=Fit]') + await fitButton.evaluate(el => el.click()) + + const svg = await page.waitForSelector('#plot-area svg') + await svg.waitForSelector('.tile circle') + const circles = await svg.$$('.tile circle') + expect(circles).toHaveLength(300) + }, 60000) +})