ParallelCoordinates

Source

AxesManager.js
import { dataToScreen } from 'paraviewweb/src/InfoViz/Native/ParallelCoordinates';
import Axis from 'paraviewweb/src/InfoViz/Native/ParallelCoordinates/Axis';
import SelectionBuilder from 'paraviewweb/src/Common/Misc/SelectionBuilder';

function toEndpoint(closeLeft, closeRight) {
const result = [];
result.push(closeLeft ? '*' : 'o');
result.push(closeRight ? '*' : 'o');
return result.join('');
}

// ----------------------------------------------------------------------------

export default class AxesManager {
constructor() {
this.axes = [];
this.listeners = [];
this.axisListChangeListeners = [];
}

clearAxes() {
if (this.axes.length) {
this.axes = [];
this.triggerAxisListChange();
}
}

updateAxes(axisList) {
if (this.axes.length === 0) {
axisList.forEach((entry) => {
this.addAxis(new Axis(entry.name, entry.range));
});
} else {
const targetList = [];
const toAdd = [];

// index axes
const nameToAxisMap = {};
this.axes.forEach((axis) => {
nameToAxisMap[axis.name] = axis;
});

axisList.forEach((entry) => {
targetList.push(entry.name);
if (!nameToAxisMap[entry.name]) {
toAdd.push(new Axis(entry.name, entry.range));
}
});

// Remove unwanted axis while keeping the previous order
const previousSize = this.axes.length;
this.axes = this.axes
.filter((axis) => targetList.indexOf(axis.name) !== -1)
.concat(toAdd);
if (toAdd.length || this.axes.length !== previousSize) {
this.triggerAxisListChange();
}
}
// Update index
this.axes.forEach((item, idx) => {
item.idx = idx;
});
}

addAxis(axis) {
axis.idx = this.axes.length;
this.axes.push(axis);
this.triggerAxisListChange();
}

getAxis(index) {
return this.axes[index];
}

getAxisByName(name) {
return this.axes.filter((axis) => axis.name === name)[0];
}

canRender() {
return this.axes.length > 1;
}

getNumberOf2DHistogram() {
return this.axes.length - 1;
}

getNumberOfAxes() {
return this.axes.length;
}

getAxesNames() {
return this.axes.map((axis) => axis.name);
}

getAxesPairs() {
const axesPairs = [];
for (let i = 1; i < this.axes.length; i++) {
axesPairs.push([this.axes[i - 1].name, this.axes[i].name]);
}
return axesPairs;
}

resetSelections(
selection = {},
triggerEvent = true,
scoreMapping = [],
scoreColorMap = []
) {
this.clearAllSelections(true);

// index axes
const nameToAxisMap = {};
this.axes.forEach((axis) => {
nameToAxisMap[axis.name] = axis;
});

// Update selections
if (selection) {
if (selection.type === 'range') {
this.selection = selection;
Object.keys(selection.range.variables).forEach((axisName) => {
if (nameToAxisMap[axisName]) {
nameToAxisMap[axisName].selections = selection.range.variables[
axisName
].map((i) => Object.assign({}, i));
if (scoreMapping && scoreMapping.length === 1) {
nameToAxisMap[axisName].selections.forEach((axisSelection) => {
axisSelection.score = scoreMapping[0];
axisSelection.color = scoreColorMap[scoreMapping[0]]
? `rgb(${scoreColorMap[scoreMapping[0]].join(',')})`
: 'rgb(105, 195, 255)';
});
}
}
});
} else if (selection.type === 'partition') {
this.selection = selection;
const axis = nameToAxisMap[selection.partition.variable];
if (axis) {
axis.selections = [];
selection.partition.dividers.forEach((divider, idx, array) => {
if (idx === 0) {
axis.selections.push({
interval: [axis.range[0], divider.value],
endpoints: toEndpoint(true, !divider.closeToLeft),
uncertainty: divider.uncertainty, // FIXME that is wrong...
color: scoreColorMap[scoreMapping[idx]]
? `rgb(${scoreColorMap[scoreMapping[idx]].join(',')})`
: 'rgb(105, 195, 255)',
score: scoreMapping[idx],
});
} else {
axis.selections.push({
interval: [array[idx - 1].value, divider.value],
endpoints: toEndpoint(
array[idx - 1].closeToLeft,
!divider.closeToLeft
),
uncertainty: divider.uncertainty, // FIXME that is wrong...
color: scoreColorMap[scoreMapping[idx]]
? `rgb(${scoreColorMap[scoreMapping[idx]].join(',')})`
: 'rgb(105, 195, 255)',
score: scoreMapping[idx],
});
}
if (idx + 1 === array.length) {
axis.selections.push({
interval: [divider.value, axis.range[1]],
endpoints: toEndpoint(divider.closeToLeft, true),
uncertainty: divider.uncertainty, // FIXME that is wrong...
color: scoreColorMap[scoreMapping[idx + 1]]
? `rgb(${scoreColorMap[scoreMapping[idx + 1]].join(',')})`
: 'rgb(105, 195, 255)',
score: scoreMapping[idx + 1],
});
}
});
}
} else if (selection.type === 'empty') {
// nothing to do we already cleared the selection
} else {
console.error(
selection,
'Parallel coordinate does not understand a selection that is not range based'
);
}
}

if (triggerEvent) {
this.triggerSelectionChange(false);
}
}

addSelection(axisIdx, start, end, endpoints, uncertainty) {
this.axes[axisIdx].addSelection(
start < end ? start : end,
end < start ? start : end,
endpoints,
uncertainty
);
this.triggerSelectionChange();
}

updateSelection(axisIdx, selectionIdx, start, end) {
this.axes[axisIdx].updateSelection(
selectionIdx,
start < end ? start : end,
end < start ? start : end
);
this.triggerSelectionChange();
}

clearSelection(axisIdx) {
this.axes[axisIdx].clearSelection();
this.triggerSelectionChange();
}

getAxisCenter(index, width) {
return (index * width) / (this.axes.length - 1);
}

toggleOrientation(index) {
this.axes[index].toggleOrientation();
}

swapAxes(aIdx, bIdx) {
if (!this.axes[aIdx] || !this.axes[bIdx]) {
return;
}
const a = this.axes[aIdx];
const b = this.axes[bIdx];
this.axes[aIdx] = b;
this.axes[bIdx] = a;
a.idx = bIdx;
b.idx = aIdx;
this.triggerAxisListChange();
}

hasSelection() {
return this.axes.filter((axis) => axis.hasSelection()).length > 0;
}

clearAllSelections(silence) {
this.axes.forEach((axis) => axis.clearSelection());
if (!silence) {
this.triggerSelectionChange();
}
}

onSelectionChange(callback) {
const listenerId = this.listeners.length;
const unsubscribe = () => {
this.listeners[listenerId] = null;
};
this.listeners.push(callback);
return { unsubscribe };
}

triggerSelectionChange(reset = true) {
setTimeout(() => {
if (reset) {
this.selection = null;
}
const selection = this.getSelection();
// Notify listeners
this.listeners.forEach((listener) => {
if (listener) {
listener(selection);
}
});
}, 0);
}

onAxisListChange(callback) {
const listenerId = this.axisListChangeListeners.length;
const unsubscribe = () => {
this.axisListChangeListeners[listenerId] = null;
};
this.axisListChangeListeners.push(callback);
return { unsubscribe };
}

triggerAxisListChange() {
setTimeout(() => {
// Notify listeners
this.axisListChangeListeners.forEach((listener) => {
if (listener) {
listener(this.getAxesPairs());
}
});
}, 0);
}

getSelection() {
if (!this.selection) {
const vars = {};
let selectionCount = 0;
this.axes.forEach((axis) => {
if (axis.hasSelection()) {
vars[axis.name] = [].concat(axis.selections);
selectionCount += 1;
}
});
this.selection = selectionCount
? SelectionBuilder.range(vars)
: SelectionBuilder.EMPTY_SELECTION;
}

return this.selection;
}

extractSelections(model, drawScore) {
const selections = [];
if (this.hasSelection()) {
this.axes.forEach((axis, index) => {
const screenX =
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft;
axis.selections.forEach((selection, selectionIndex) => {
if (drawScore && selection.score !== undefined) {
if (!drawScore(selection.score)) {
return; // Skip unwanted score
}
}
selections.push({
index,
selectionIndex,
screenX,
screenRangeY: [
dataToScreen(model, selection.interval[0], axis),
dataToScreen(model, selection.interval[1], axis),
],
color: selection.color ? selection.color : 'rgb(105, 195, 255)',
});
});
});
}
return selections;
}

extractAxesControl(model) {
const controlsDataModel = [];
this.axes.forEach((axis, index) => {
controlsDataModel.push({
orient: !axis.isUpsideDown(),
centerX:
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft,
centerY: model.canvasArea.height - model.borderOffsetBottom + 30, // FIXME what is 30?
});
});

// Tag first/last axis
controlsDataModel[0].pos = -1;
controlsDataModel[controlsDataModel.length - 1].pos = 1;

return controlsDataModel;
}

extractLabels(model) {
const labelModel = [];

this.axes.forEach((axis, index) => {
labelModel.push({
name: axis.name,
centerX:
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft,
annotated: axis.hasSelection(),
align: 'middle',
});
});

// Tag first/last axis
labelModel[0].align = 'start';
labelModel[labelModel.length - 1].align = 'end';

return labelModel;
}

extractAxisTicks(model) {
const tickModel = [];

this.axes.forEach((axis, index) => {
tickModel.push({
value: !axis.upsideDown ? axis.range[1] : axis.range[0],
xpos:
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft,
ypos: model.borderOffsetTop - 4,
align: 'middle',
});
tickModel.push({
value: !axis.upsideDown ? axis.range[0] : axis.range[1],
xpos:
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft,
ypos: model.borderOffsetTop + model.drawableArea.height + 13,
align: 'middle',
});
});

// Make adjustments to ticks for first and last axes
tickModel[0].align = 'start';
tickModel[1].align = 'start';
tickModel[0].xpos -= model.axisWidth / 2;
tickModel[1].xpos -= model.axisWidth / 2;

tickModel[this.axes.length * 2 - 1].align = 'end';
tickModel[this.axes.length * 2 - 2].align = 'end';
tickModel[this.axes.length * 2 - 1].xpos += model.axisWidth / 2;
tickModel[this.axes.length * 2 - 2].xpos += model.axisWidth / 2;

return tickModel;
}

extractAxesCenters(model) {
const axesCenters = [];
this.axes.forEach((axis, index) => {
axesCenters.push(
this.getAxisCenter(index, model.drawableArea.width) +
model.borderOffsetLeft
);
});
return axesCenters;
}
}
Axis.js
export default class Axis {
constructor(name, range = [0, 1]) {
this.name = name;
this.range = [].concat(range);
this.upsideDown = false;
this.selections = [];
}

toggleOrientation() {
this.upsideDown = !this.upsideDown;
}

isUpsideDown() {
return this.upsideDown;
}

hasSelection() {
return this.selections.length > 0;
}

updateRange(newRange) {
if (
this.range[0] !== newRange[0] ||
this.range[1] !== newRange[1] ||
this.range[1] === this.range[0]
) {
this.range[0] = newRange[0];
this.range[1] = newRange[1];
if (this.range[0] === this.range[1]) {
this.range[1] += 1;
}
}
}

updateSelection(selectionIndex, start, end) {
const entry = [start, end];
this.selections[selectionIndex].interval = entry;
// if entire selection is outside range, delete it.
if (
(start < this.range[0] && end < this.range[0]) ||
(end > this.range[1] && start > this.range[1])
) {
this.selections.splice(selectionIndex, 1);
return;
}

// Clamp to axis range
if (start < this.range[0]) {
entry[0] = this.range[0];
}

if (end > this.range[1]) {
entry[1] = this.range[1];
}

// notification handled by AxesManager
}

addSelection(start, end, endpoints = '**', uncertainty) {
const interval = [
start < this.range[0] ? this.range[0] : start,
end < this.range[1] ? end : this.range[1],
];
this.selections.push({ interval, endpoints, uncertainty });
}

clearSelection() {
this.selections = [];
}
}
AxisControl-svg.html
<g class="axis-controls-group-container" width="108" height="50" viewBox="0 0 108 50">
<g class="axis-controls-group">
<rect class="center-rect" x="28" y="1" width="52" height="48"></rect>
<rect class="right-rect" x="82" y="1" width="25" height="48"></rect>
<rect class="left-rect" x="1" y="1" width="25" height="48"></rect>
<polygon class="top" points="54 1 78 23 30 23 "></polygon>
<polygon class="right" transform="translate(94.000000, 25.000000) rotate(-270.000000) translate(-94.000000, -25.000000) " points="94 14 118 36 70 36 "></polygon>
<polygon class="left" transform="translate(14.000000, 25.000000) scale(-1, 1) rotate(-270.000000) translate(-14.000000, -25.000000) " points="14 14 38 36 -10 36 "></polygon>
<polygon class="bottom" transform="translate(54.000000, 38.000000) scale(1, -1) translate(-54.000000, -38.000000) " points="54 27 78 49 30 49 "></polygon>
</g>
</g>
body.html
<div class="parallel-coords-placeholder">
<div class="pc-placeholder-row">
<span class="pc-placeholder-title">Parallel Coordinates</span>
</div>
<div class="pc-placeholder-row">
<img class="pc-placeholder-image"/>
</div>
<div class="pc-placeholder-row">
<span class="pc-placeholder-info">Please select two or more variables</span>
</div>
</div>
index.js
/* global document */

import d3 from 'd3';
import style from 'PVWStyle/InfoVizNative/ParallelCoordinates.mcss';

import AnnotationBuilder from 'paraviewweb/src/Common/Misc/AnnotationBuilder';
import AxesManager from 'paraviewweb/src/InfoViz/Native/ParallelCoordinates/AxesManager';
// import axisControlSvg from './AxisControl-svg.html';
import CompositeClosureHelper from 'paraviewweb/src/Common/Core/CompositeClosureHelper';
import htmlContent from 'paraviewweb/src/InfoViz/Native/ParallelCoordinates/body.html';
import iconImage from 'paraviewweb/src/InfoViz/Native/ParallelCoordinates/ParallelCoordsIconSmall.png';

// ----------------------------------------------------------------------------
// Global
// ----------------------------------------------------------------------------

export function affine(inMin, val, inMax, outMin, outMax) {
return ((val - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin;
}

export function perfRound(val) {
/* eslint-disable no-bitwise */
return (0.5 + val) | 0;
/* eslint-enable no-bitwise */
}

export function dataToScreen(model, dataY, axis) {
return perfRound(
!axis.isUpsideDown()
? affine(
axis.range[0],
dataY,
axis.range[1],
model.canvasArea.height - model.borderOffsetBottom,
model.borderOffsetTop
)
: affine(
axis.range[0],
dataY,
axis.range[1],
model.borderOffsetTop,
model.canvasArea.height - model.borderOffsetBottom
)
);
}

export function screenToData(model, screenY, axis) {
return !axis.isUpsideDown()
? affine(
model.canvasArea.height - model.borderOffsetBottom,
screenY,
model.borderOffsetTop,
axis.range[0],
axis.range[1]
)
: affine(
model.borderOffsetTop,
screenY,
model.canvasArea.height - model.borderOffsetBottom,
axis.range[0],
axis.range[1]
);
}

export function toColorArray(colorString) {
return [
Number.parseInt(colorString.slice(1, 3), 16),
Number.parseInt(colorString.slice(3, 5), 16),
Number.parseInt(colorString.slice(5, 7), 16),
];
}

// ----------------------------------------------------------------------------
// Parallel Coordinate
// ----------------------------------------------------------------------------

function parallelCoordinate(publicAPI, model) {
// Private internal
const scoreToColor = [];
let lastAnnotationPushed = null;

function updateSizeInformation() {
if (!model.canvas) {
return;
}
model.canvasArea = {
width: model.canvas.width,
height: model.canvas.height,
};
model.drawableArea = {
width:
model.canvasArea.width -
(model.borderOffsetLeft + model.borderOffsetRight),
height:
model.canvasArea.height -
(model.borderOffsetTop + model.borderOffsetBottom),
};
}

// -======================================================
model.canvas = document.createElement('canvas');
model.canvas.style.position = 'absolute';
model.canvas.style.top = 0;
model.canvas.style.right = 0;
model.canvas.style.bottom = 0;
model.canvas.style.left = 0;
model.ctx = model.canvas.getContext('2d');

model.fgCanvas = document.createElement('canvas');
model.fgCtx = model.fgCanvas.getContext('2d');
model.bgCanvas = document.createElement('canvas');
model.bgCtx = model.bgCanvas.getContext('2d');

model.axes = new AxesManager();

// Local cache of the selection data
model.selectionData = null;
model.visibleScores = [0, 1, 2];

function drawSelectionData(score) {
if (model.axes.selection && model.visibleScores) {
return model.visibleScores.indexOf(score) !== -1;
}
return true;
}

function drawSelectionBars(selectionBarModel) {
const resizeTargetSize = 6;
const svg = d3.select(model.container).select('svg');
const selBarGroup = svg.select('g.selection-bars');

// Make the selection bars
const selectionBarNodes = selBarGroup
.selectAll('rect.selection-bars')
.data(selectionBarModel);

selectionBarNodes
.enter()
.append('rect')
.classed('selection-bars', true)
.classed(style.selectionBars, true);

selectionBarNodes.exit().remove();

selBarGroup
.selectAll('rect.selection-bars')
.classed(style.controlItem, true)
.style('fill', (d, i) => d.color)
.attr('width', model.selectionBarWidth)
.attr('height', (d, i) => {
let barHeight = d.screenRangeY[1] - d.screenRangeY[0];
if (barHeight < 0) barHeight = -barHeight;
return barHeight;
})
.attr('transform', (d, i) => {
const startPoint =
d.screenRangeY[0] > d.screenRangeY[1]
? d.screenRangeY[1]
: d.screenRangeY[0];
return `translate(${
d.screenX - model.selectionBarWidth / 2
}, ${startPoint})`;
})
.on('mousemove', function inner(d, i) {
const moveCoords = d3.mouse(svg.node());
const resizeHandle = Math.min(
resizeTargetSize,
Math.floor(Math.abs(d.screenRangeY[1] - d.screenRangeY[0]) / 3)
);
if (
Math.abs(d.screenRangeY[0] - moveCoords[1]) <= resizeHandle ||
Math.abs(d.screenRangeY[1] - moveCoords[1]) <= resizeHandle
) {
d3.select(this).style('cursor', 'ns-resize');
} else {
d3.select(this).style('cursor', null);
}
})
.on('mouseout', function inner(d, i) {
d3.select(this).style('cursor', null);
})
.on('mousedown', function inner(d, i) {
d3.event.preventDefault();
const downCoords = d3.mouse(svg.node());
// resize within X pixels of the ends, or 1/3 of the bar size, whichever is less.
const resizeHandle = Math.min(
resizeTargetSize,
Math.floor(Math.abs(d.screenRangeY[1] - d.screenRangeY[0]) / 3)
);
let resizeIndex = -1;
let resizeOffset = 0;
if (Math.abs(d.screenRangeY[0] - downCoords[1]) <= resizeHandle) {
resizeIndex = 0;
resizeOffset = d.screenRangeY[0] - downCoords[1];
} else if (
Math.abs(d.screenRangeY[1] - downCoords[1]) <= resizeHandle
) {
resizeIndex = 1;
resizeOffset = d.screenRangeY[1] - downCoords[1];
}

svg.on('mousemove', (md, mi) => {
const moveCoords = d3.mouse(svg.node());
let deltaYScreen = moveCoords[1] - downCoords[1];
if (resizeIndex >= 0) {
d.screenRangeY[resizeIndex] = moveCoords[1] + resizeOffset;
deltaYScreen = 0;
}
const startPoint =
d.screenRangeY[0] > d.screenRangeY[1]
? d.screenRangeY[1]
: d.screenRangeY[0];
let barHeight = d.screenRangeY[1] - d.screenRangeY[0];
if (barHeight < 0) barHeight = -barHeight;
d3.select(this)
.attr(
'transform',
`translate(${d.screenX - model.selectionBarWidth / 2}, ${
startPoint + deltaYScreen
})`
)
.attr('height', barHeight);
});

svg.on('mouseup', (md, mi) => {
const upCoords = d3.mouse(svg.node());
const deltaYScreen =
resizeIndex === -1 ? upCoords[1] - downCoords[1] : 0;
const startPoint =
d.screenRangeY[0] > d.screenRangeY[1]
? d.screenRangeY[1]
: d.screenRangeY[0];
let barHeight = d.screenRangeY[1] - d.screenRangeY[0];
if (barHeight < 0) barHeight = -barHeight;
const newStart = startPoint + deltaYScreen;
const newEnd = newStart + barHeight;
svg.on('mousemove', null);
svg.on('mouseup', null);

const axis = model.axes.getAxis(d.index);
// Note: if bar is moved entirely outside the current range, it will be deleted.
model.axes.updateSelection(
d.index,
d.selectionIndex,
screenToData(model, newStart, axis),
screenToData(model, newEnd, axis)
);
});
});
}

function drawAxisControls(controlsDataModel) {
// Manipulate the control widgets svg DOM
const svgGr = d3
.select(model.container)
.select('svg')
.select('g.axis-control-elements');

const axisControlNodes = svgGr
.selectAll('g.axis-control-element')
.data(controlsDataModel);

const axisControls = axisControlNodes
.enter()
.append('g')
.classed('axis-control-element', true)
.classed(style.axisControlElements, true)
// Can't use .html on svg without polyfill: https://github.com/d3/d3-3.x-api-reference/blob/master/Selections.md#html
// fails in IE11. Replace by explicit DOM manipulation.
// .html(axisControlSvg);
.append('g')
.classed('axis-controls-group-container', true)
.attr('width', 108)
.attr('height', 50)
.attr('viewBox', '0 0 108 50')
.append('g')
.classed('axis-controls-group', true);

axisControls
.append('rect')
.classed('center-rect', true)
.attr('x', 28)
.attr('y', 1)
.attr('width', 52)
.attr('height', 48);
axisControls
.append('rect')
.classed('right-rect', true)
.attr('x', 82)
.attr('y', 1)
.attr('width', 25)
.attr('height', 48);
axisControls
.append('rect')
.classed('left-rect', true)
.attr('x', 1)
.attr('y', 1)
.attr('width', 25)
.attr('height', 48);
axisControls
.append('polygon')
.classed('top', true)
.attr('points', '54 1 78 23 30 23 ');
axisControls
.append('polygon')
.classed('right', true)
.attr('points', '94 14 118 36 70 36 ')
.attr(
'transform',
'translate(94.0, 25.0) rotate(-270.0) translate(-94.0, -25.0) '
);
axisControls
.append('polygon')
.classed('left', true)
.attr('points', '14 14 38 36 -10 36 ')
.attr(
'transform',
'translate(14.0, 25.0) scale(-1, 1) rotate(-270.0) translate(-14.0, -25.0) '
);
axisControls
.append('polygon')
.classed('bottom', true)
.attr('points', '54 27 78 49 30 49 ')
.attr(
'transform',
'translate(54.0, 38.0) scale(1, -1) translate(-54.0, -38.0) '
);

axisControlNodes.exit().remove();

const scale = 0.5;
axisControlNodes
.classed(style.upsideDown, (d, i) => !d.orient)
.classed(style.rightsideUp, (d, i) => d.orient)
.attr('transform', function inner(d, i) {
const elt = d3.select(this).select('g.axis-controls-group-container');
const tx = d.centerX - (elt.attr('width') * scale) / 2;
const ty = d.centerY - (elt.attr('height') * scale) / 2;
return `translate(${tx}, ${ty}) scale(${scale})`;
})
.on('click', function inner(d, i) {
const cc = d3.mouse(this);
const elt = d3.select(this).select('g.axis-controls-group-container');
const ratio = cc[0] / elt.attr('width');
if (ratio < 0.28) {
// left arrow click
model.axes.swapAxes(i - 1, i);
} else if (ratio < 0.73) {
// up/down click
model.axes.toggleOrientation(i);
publicAPI.render();
} else {
// right arrow click
model.axes.swapAxes(i, i + 1);
}
})
.selectAll('.axis-controls-group-container')
.classed(style.axisControlsGroupContainer, true);
}

function drawAxisLabels(labelDataModel) {
const ypos = 15;
const glyphRegion = 22;
const glyphPadding = 3;
const svg = d3.select(model.container).select('svg');

if (model.provider && model.provider.isA('LegendProvider')) {
// Add legend key
labelDataModel.forEach((entry) => {
entry.legend = model.provider.getLegend(entry.name);
});
let glyphSize = glyphRegion - glyphPadding - glyphPadding;
if (glyphSize % 2 !== 0) {
glyphSize += 1;
}

const glyphGroup = svg
.selectAll('g.glyphs')
.data(labelDataModel, (d) => (d ? d.name : 'none'));

glyphGroup.exit().remove();

const glyphEnter = glyphGroup.enter().append('g').classed('glyphs', true);

// Create nested structure
const svgGroup = glyphEnter.append('svg');
svgGroup.append('use');

// add a tooltip
glyphEnter.append('title').text((d) => d.name);

glyphGroup
.attr(
'transform',
(d, i) => `translate(${d.centerX - glyphSize * 0.5}, ${glyphPadding})`
)
.on('click', (d, i) => {
if (d.annotated) {
model.axes.clearSelection(i);
}
});

glyphEnter.each(function applyLegendStyle(d, i) {
d3.select(this)
.select('svg')
.attr('viewBox', d.legend.shape.viewBox)
.attr('fill', d.legend.color)
.attr('stroke', 'black')
.attr('width', glyphSize)
.attr('height', glyphSize)
.style('color', d.legend.color) // Firefox SVG use color bug workaround fix
.classed(style.clickable, d.annotated)
.select('use')
.classed(style.colorToFill, true) // Firefox SVG use color bug workaround fix
.classed(style.blackStroke, true)
.attr('xlink:href', `#${d.legend.shape.id}`);
});

// Augment the legend glyphs with extra DOM for annotated axes
const indicatorGroup = svg.select('g.axis-annotation-indicators');
const indicatorNodes = indicatorGroup
.selectAll('rect.axis-annotation-indicators')
.data(labelDataModel);

indicatorNodes
.enter()
.append('rect')
.classed('axis-annotation-indicators', true)
.classed(style.axisAnnotationIndicators, true);

indicatorNodes.exit().remove();

indicatorGroup
.selectAll('rect.axis-annotation-indicators')
.attr('width', glyphSize + 3)
.attr('height', glyphSize + 3)
.attr(
'transform',
(d, i) =>
`translate(${d.centerX - (glyphSize * 0.5 + 1)}, ${
glyphPadding - 1.5
})`
)
.classed(style.axisAnnotated, (d, i) => d.annotated);
} else {
// Now manage the svg dom for the axis labels
const axisLabelNodes = svg
.selectAll('text.axis-labels')
.data(labelDataModel);

axisLabelNodes
.enter()
.append('text')
.classed('axis-labels', true)
.classed(style.axisLabels, true);

axisLabelNodes.exit().remove();

svg
.selectAll('text.axis-labels')
.text((d, i) => d.name)
.classed(style.annotatedAxisText, (d, i) => d.annotated)
.on('click', (d, i) => {
model.axes.clearSelection(i);
})
.attr('text-anchor', (d, i) => d.align)
.attr('transform', (d, i) => `translate(${d.centerX}, ${ypos})`);
}
}

function drawAxisTicks(tickDataModel) {
// Manage the svg dom for the axis ticks
const svg = d3.select(model.container).select('svg');
const ticksGroup = svg.select('g.axis-ticks');
const axisTickNodes = ticksGroup
.selectAll('text.axis-ticks')
.data(tickDataModel);

axisTickNodes
.enter()
.append('text')
.classed('axis-ticks', true)
.classed(style.axisTicks, true);

axisTickNodes.exit().remove();

const formatter = d3.format('.3s');
ticksGroup
.selectAll('text.axis-ticks')
.text((d, i) => formatter(d.value))
.attr('text-anchor', (d, i) => d.align)
.attr('transform', (d, i) => `translate(${d.xpos}, ${d.ypos})`);
}

function axisMouseDragHandler(data, index) {
const svg = d3.select(model.container).select('svg');
const coords = d3.mouse(model.container);
const pendingSelection = svg.select('rect.axis-selection-pending');
if (pendingSelection) {
const rectHeight = coords[1] - pendingSelection.attr('data-initial-y');
if (rectHeight >= 0) {
pendingSelection.attr('height', rectHeight);
} else {
pendingSelection
.attr(
'transform',
`translate(${pendingSelection.attr('data-initial-x')}, ${
coords[1]
})`
)
.attr('height', -rectHeight);
}
}
}

function drawAxes(axesCenters) {
if (axesCenters.length <= 1) {
// let's not do anything if we don't have enough axes for rendering.
return;
}

const svg = d3.select(model.container).select('svg');
const axisLineGroup = svg.select('g.axis-lines');

// Now manage the svg dom
const axisLineNodes = axisLineGroup
.selectAll('rect.axis-lines')
.data(axesCenters);

axisLineNodes
.enter()
.append('rect')
.classed('axis-lines', true)
.classed(style.axisLines, true);

axisLineNodes.exit().remove();

axisLineGroup
.selectAll('rect.axis-lines')
.classed(style.controlItem, true)
.attr(
'height',
model.canvasArea.height -
model.borderOffsetBottom -
model.borderOffsetTop
)
.attr('width', model.axisWidth)
.attr(
'transform',
(d, i) =>
`translate(${d - model.axisWidth / 2}, ${model.borderOffsetTop})`
)
.on('mousedown', (d, i) => {
d3.event.preventDefault();
const coords = d3.mouse(model.container);
const initialY = coords[1];
const initialX = d - model.selectionBarWidth / 2;
const prect = svg.append('rect');
prect
.classed('axis-selection-pending', true)
.classed(style.selectionBars, true)
.attr('height', 0.5)
.attr('width', model.selectionBarWidth)
.attr('transform', `translate(${initialX}, ${initialY})`)
.attr('data-initial-x', initialX)
.attr('data-initial-y', initialY)
.attr('data-index', i);

svg.on('mousemove', axisMouseDragHandler);
svg.on('mouseup', (data, index) => {
const finalY = d3.mouse(model.container)[1];
svg.select('rect.axis-selection-pending').remove();
svg.on('mousemove', null);
svg.on('mouseup', null);

const axis = model.axes.getAxis(i);
model.axes.addSelection(
i,
screenToData(model, initialY, axis),
screenToData(model, finalY, axis)
);
});
});
}

function drawPolygons(axesCenters, gCtx, idxOne, idxTwo, histogram, colors) {
if (!histogram) {
return;
}
const axisOne = model.axes.getAxis(idxOne);
const axisTwo = model.axes.getAxis(idxTwo);
const xleft = axesCenters[idxOne];
const xright = axesCenters[idxTwo];
let bin = null;
let opacity = 0.0;
let yleft1 = 0.0;
let yleft2 = 0.0;
let yright1 = 0.0;
let yright2 = 0.0;
let yLeftMin = 0;
let yLeftMax = 0;
let yRightMin = 0;
let yRightMax = 0;

// Ensure proper range for X
const deltaOne =
(axisOne.range[1] - axisOne.range[0]) /
(histogram.numberOfBins || model.numberOfBins);
const deltaTwo =
(axisTwo.range[1] - axisTwo.range[0]) /
(histogram.numberOfBins || model.numberOfBins);

for (let i = 0; i < histogram.bins.length; ++i) {
bin = histogram.bins[i];
opacity = affine(
0,
bin.count,
model.maxBinCountForOpacityCalculation,
0.0,
1.0
);
yleft1 = dataToScreen(model, bin.x, axisOne);
yleft2 = dataToScreen(model, bin.x + deltaOne, axisOne);
yright1 = dataToScreen(model, bin.y, axisTwo);
yright2 = dataToScreen(model, bin.y + deltaTwo, axisTwo);
yLeftMin = 0;
yLeftMax = 0;
yRightMin = 0;
yRightMax = 0;

if (yleft1 <= yleft2) {
yLeftMin = yleft1;
yLeftMax = yleft2;
} else {
yLeftMin = yleft2;
yLeftMax = yleft1;
}

if (yright1 <= yright2) {
yRightMin = yright1;
yRightMax = yright2;
} else {
yRightMin = yright2;
yRightMax = yright1;
}

gCtx.beginPath();
gCtx.moveTo(xleft, yLeftMin);
gCtx.lineTo(xleft, yLeftMax);
gCtx.lineTo(xright, yRightMax);
gCtx.lineTo(xright, yRightMin);
gCtx.closePath();
gCtx.fillStyle = `rgba(${colors[0]},${colors[1]},${colors[2]},${opacity})`;
gCtx.fill();
}
}

publicAPI.render = () => {
if (
!model.allBgHistogram2dData ||
!model.axes.canRender() ||
!model.container ||
model.containerHidden === true
) {
d3.select(model.container)
.select('svg.parallel-coords-overlay')
.classed(style.hidden, true);
d3.select(model.container).select('canvas').classed(style.hidden, true);
d3.select(model.container)
.select('div.parallel-coords-placeholder')
.classed(style.hidden, false);
return;
}

d3.select(model.container)
.select('svg.parallel-coords-overlay')
.classed(style.hidden, false);
d3.select(model.container).select('canvas').classed(style.hidden, false);
d3.select(model.container)
.select('div.parallel-coords-placeholder')
.classed(style.hidden, true);

model.ctx.globalAlpha = 1.0;

// Update canvas area and drawable info
updateSizeInformation();

model.hoverIndicatorHeight = model.drawableArea.height / model.numberOfBins;

model.fgCanvas.width = model.canvas.width;
model.fgCanvas.height = model.canvas.height;
model.bgCanvas.width = model.canvas.width;
model.bgCanvas.height = model.canvas.height;

const svg = d3.select(model.container).select('svg');
svg
.attr('width', model.canvas.width)
.attr('height', model.canvas.height)
.classed('parallel-coords-overlay', true)
.classed(style.parallelCoordsOverlay, true);

if (d3.select(model.container).selectAll('g').empty()) {
// Have not added groups yet, do so now. Order matters.
svg.append('g').classed('axis-lines', true);
svg.append('g').classed('selection-bars', true);
svg.append('g').classed('hover-bins', true);
svg.append('g').classed('axis-annotation-indicators', true);
svg.append('g').classed('axis-control-elements', true);
svg.append('g').classed('axis-ticks', true);
svg.append('g').classed('glyphs', true);
}

model.ctx.clearRect(0, 0, model.canvasArea.width, model.canvasArea.height);
model.fgCtx.clearRect(
0,
0,
model.canvasArea.width,
model.canvasArea.height
);
model.bgCtx.clearRect(
0,
0,
model.canvasArea.width,
model.canvasArea.height
);

// First lay down the "context" polygons
model.maxBinCountForOpacityCalculation =
model.allBgHistogram2dData.maxCount;

const nbPolyDraw = model.axes.getNumberOf2DHistogram();
const axesCenters = model.axes.extractAxesCenters(model);
if (!model.showOnlySelection) {
for (let j = 0; j < nbPolyDraw; ++j) {
const axisOne = model.axes.getAxis(j);
const axisTwo = model.axes.getAxis(j + 1);
const histo2D = model.allBgHistogram2dData[axisOne.name]
? model.allBgHistogram2dData[axisOne.name][axisTwo.name]
: null;
drawPolygons(
axesCenters,
model.bgCtx,
j,
j + 1,
histo2D,
model.polygonColors
);
}

model.ctx.globalAlpha = model.polygonOpacityAdjustment;
model.ctx.drawImage(
model.bgCanvas,
0,
0,
model.canvasArea.width,
model.canvasArea.height,
0,
0,
model.canvasArea.width,
model.canvasArea.height
);
}

// If there is a selection, draw that (the "focus") on top of the polygons
if (model.selectionData) {
// Extract selection histogram2d
const polygonsQueue = [];
let maxCount = 0;
let missingData = false;

const processHistogram = (h, k) => {
if (drawSelectionData(h.role.score)) {
maxCount = maxCount > h.maxCount ? maxCount : h.maxCount;
// Add in queue
polygonsQueue.push([
axesCenters,
model.fgCtx,
k,
k + 1,
h,
scoreToColor[h.role.score] || model.selectionColors,
]);
}
};

for (let k = 0; k < nbPolyDraw && !missingData; ++k) {
const histo =
model.selectionData && model.selectionData[model.axes.getAxis(k).name]
? model.selectionData[model.axes.getAxis(k).name][
model.axes.getAxis(k + 1).name
]
: null;
missingData = !histo;

if (histo) {
histo.forEach((h) => processHistogram(h, k));
}
}

if (!missingData) {
model.maxBinCountForOpacityCalculation = maxCount;
polygonsQueue.forEach((req) => drawPolygons(...req));
model.ctx.globalAlpha = model.selectionOpacityAdjustment;
model.ctx.drawImage(
model.fgCanvas,
0,
0,
model.canvasArea.width,
model.canvasArea.height,
0,
0,
model.canvasArea.width,
model.canvasArea.height
);
}
}

model.ctx.globalAlpha = 1.0;

// Now draw all the decorations and controls
drawAxisLabels(model.axes.extractLabels(model));
drawAxisTicks(model.axes.extractAxisTicks(model));
drawAxes(axesCenters);
drawSelectionBars(model.axes.extractSelections(model, drawSelectionData));
drawAxisControls(model.axes.extractAxesControl(model));
};

// -------------- Used to speed up action of opacity sliders ----------------
// function fastRender() {
// model.ctx.clearRect(0, 0, model.canvasArea.width, model.canvasArea.height);

// model.ctx.globalAlpha = model.polygonOpacityAdjustment;
// model.ctx.drawImage(model.bgCanvas,
// 0, 0, model.canvasArea.width, model.canvasArea.height,
// 0, 0, model.canvasArea.width, model.canvasArea.height);

// model.ctx.globalAlpha = model.selectionOpacityAdjustment;
// model.ctx.drawImage(model.fgCanvas,
// 0, 0, model.canvasArea.width, model.canvasArea.height,
// 0, 0, model.canvasArea.width, model.canvasArea.height);

// model.ctx.globalAlpha = 1.0;

// const axesCenters = model.axes.extractAxesCenters(model);

// drawAxes(axesCenters);
// drawSelectionBars(model.axes.extractSelections(model));
// drawAxisLabels(model.axes.extractLabels(model));
// drawAxisControls(model.axes.extractAxesControl(model));
// }

publicAPI.propagateAnnotationInsteadOfSelection = (
useAnnotation = true,
defaultScore = 0,
defaultWeight = 0
) => {
model.useAnnotation = useAnnotation;
model.defaultScore = defaultScore;
model.defaultWeight = defaultWeight;
};

publicAPI.setVisibleScoresForSelection = (scoreList) => {
model.visibleScores = scoreList;
if (
model.selectionDataSubscription &&
model.visibleScores &&
model.propagatePartitionScores
) {
model.selectionDataSubscription.update(
model.axes.getAxesPairs(),
model.visibleScores
);
}
};

publicAPI.setScores = (scores) => {
model.scores = scores;
if (model.scores) {
publicAPI.setVisibleScoresForSelection(scores.map((score, idx) => idx));
model.scores.forEach((score, idx) => {
scoreToColor[idx] = toColorArray(score.color);
});
}
};

if (model.provider && model.provider.isA('ScoresProvider')) {
publicAPI.setScores(model.provider.getScores());
model.subscriptions.push(
model.provider.onScoresChange(publicAPI.setScores)
);
}

publicAPI.resize = () => {
if (!model.container) {
return;
}
const clientRect = model.canvas.parentElement.getBoundingClientRect();
model.canvas.setAttribute('width', clientRect.width);
model.canvas.setAttribute('height', clientRect.height);
d3.select(model.container)
.select('svg')
.selectAll('rect.hover-bin-indicator')
.remove();
if (clientRect.width !== 0 && clientRect.height !== 0) {
model.containerHidden = false;
publicAPI.render();
} else {
model.containerHidden = true;
}
};

publicAPI.setContainer = (element) => {
if (model.container) {
while (model.container.firstChild) {
model.container.removeChild(model.container.firstChild);
}
model.container = null;
}

model.container = element;

if (model.container) {
model.container.innerHTML = htmlContent;
d3.select(model.container)
.select('div.parallel-coords-placeholder')
.select('img')
.attr('src', iconImage);
model.container.appendChild(model.canvas);
d3.select(model.container).append('svg');
publicAPI.resize();
}
};

function binNumberToScreenOffset(binNumber, rightSideUp) {
let screenY = affine(
0,
binNumber,
model.numberOfBins,
model.canvasArea.height - model.borderOffsetBottom,
model.borderOffsetTop
);
screenY -= model.hoverIndicatorHeight;

if (rightSideUp === false) {
screenY = affine(
0,
binNumber,
model.numberOfBins,
model.borderOffsetTop,
model.canvasArea.height - model.borderOffsetBottom
);
}

return perfRound(screenY);
}

function handleHoverBinUpdate(data) {
if (!model.axes.canRender() || model.containerHidden === true) {
// let's not do anything if we don't have enough axes for rendering.
return;
}

// First update our internal data model
model.hoverBinData = [];
Object.keys(data.state).forEach((pName) => {
const binList = data.state[pName];
if (model.axes.getAxisByName(pName) && binList.indexOf(-1) === -1) {
for (let i = 0; i < binList.length; ++i) {
model.hoverBinData.push({
name: pName,
bin: binList[i],
});
}
}
});

// Now manage the svg dom
const hoverBinNodes = d3
.select(model.container)
.select('svg')
.select('g.hover-bins')
.selectAll('rect.hover-bin-indicator')
.data(model.hoverBinData);

hoverBinNodes
.enter()
.append('rect')
.classed(style.hoverBinIndicator, true)
.classed('hover-bin-indicator', true);

hoverBinNodes.exit().remove();

const axesCenters = model.axes.extractAxesCenters(model);
d3.select(model.container)
.select('svg')
.select('g.hover-bins')
.selectAll('rect.hover-bin-indicator')
.attr('height', model.hoverIndicatorHeight)
.attr('width', model.hoverIndicatorWidth)
.attr('transform', (d, i) => {
const axis = model.axes.getAxisByName(d.name);
const screenOffset = binNumberToScreenOffset(
d.bin,
!axis.isUpsideDown()
);
return `translate(${
axesCenters[axis.idx] - model.hoverIndicatorWidth / 2
}, ${screenOffset})`;
});
}

// Attach listener to provider
model.subscriptions.push({ unsubscribe: publicAPI.setContainer });

// Handle active field change, update axes
if (model.provider.isA('FieldProvider')) {
// Monitor any change
model.subscriptions.push(
model.provider.onFieldChange(() => {
model.axes.updateAxes(
model.provider.getActiveFieldNames().map((name) => ({
name,
range: model.provider.getField(name).range,
}))
);

if (model.provider.isA('Histogram2DProvider')) {
model.histogram2DDataSubscription.update(model.axes.getAxesPairs());
}

if (model.provider.isA('SelectionProvider')) {
model.selectionDataSubscription.update(model.axes.getAxesPairs());
}
})
);
// Use initial state
model.axes.updateAxes(
model.provider
.getActiveFieldNames()
.map((name) => ({ name, range: model.provider.getField(name).range }))
);
}

// Handle bin hovering
if (model.provider.onHoverBinChange) {
model.subscriptions.push(
model.provider.onHoverBinChange(handleHoverBinUpdate)
);
}

if (model.provider.isA('Histogram2DProvider')) {
model.histogram2DDataSubscription = model.provider.subscribeToHistogram2D(
(allBgHistogram2d) => {
// Update axis range
model.axes.getAxesPairs().forEach((pair, idx) => {
const hist2d = allBgHistogram2d[pair[0]][pair[1]];
if (hist2d) {
model.axes.getAxis(idx).updateRange(hist2d.x.extent);
model.axes.getAxis(idx + 1).updateRange(hist2d.y.extent);
}
});

const topLevelList = Object.keys(allBgHistogram2d);
// We always get a maxCount, anything additional must be histogram2d
if (topLevelList.length > 1) {
model.allBgHistogram2dData = allBgHistogram2d;
publicAPI.render();
} else {
model.allBgHistogram2dData = null;
publicAPI.render();
}
},
model.axes.getAxesPairs(),
{
numberOfBins: model.numberOfBins,
partial: false,
}
);

model.subscriptions.push(
model.axes.onAxisListChange((axisPairs) => {
model.histogram2DDataSubscription.update(axisPairs);
})
);

model.subscriptions.push(model.histogram2DDataSubscription);
}

if (model.provider.isA('SelectionProvider')) {
model.selectionDataSubscription = model.provider.subscribeToDataSelection(
'histogram2d',
(data) => {
model.selectionData = data;
if (model.provider.getAnnotation()) {
model.axes.resetSelections(
model.provider.getAnnotation().selection,
false,
model.provider.getAnnotation().score,
scoreToColor
);
if (data['##annotationGeneration##'] !== undefined) {
if (
model.provider.getAnnotation().generation ===
data['##annotationGeneration##']
) {
// render from selection data change (same generation)
publicAPI.render();
}
} else {
// render from selection data change (no generation)
publicAPI.render();
}
} else {
// render from selection data change (no annotation)
publicAPI.render();
}
},
model.axes.getAxesPairs(),
{
partitionScores: model.visibleScores,
numberOfBins: model.numberOfBins,
}
);

model.subscriptions.push(model.selectionDataSubscription);

model.subscriptions.push(
model.provider.onSelectionChange((sel) => {
if (!model.useAnnotation) {
if (sel && sel.type === 'empty') {
model.selectionData = null;
}
model.axes.resetSelections(sel, false);
publicAPI.render();
}
})
);
model.subscriptions.push(
model.provider.onAnnotationChange((annotation) => {
if (annotation && annotation.selection.type === 'empty') {
model.selectionData = null;
}

if (
lastAnnotationPushed &&
annotation.selection.type === 'range' &&
annotation.id === lastAnnotationPushed.id &&
annotation.generation === lastAnnotationPushed.generation + 1
) {
// Assume that it is still ours but edited by someone else
lastAnnotationPushed = annotation;

// Capture the score and update our default
model.defaultScore = lastAnnotationPushed.score[0];
}
model.axes.resetSelections(
annotation.selection,
false,
annotation.score,
scoreToColor
);
})
);
model.subscriptions.push(
model.axes.onSelectionChange(() => {
if (model.useAnnotation) {
lastAnnotationPushed = model.provider.getAnnotation();

// If parttion annotation special handle
if (
lastAnnotationPushed &&
lastAnnotationPushed.selection.type === 'partition'
) {
const axisIdxToClear = model.axes
.getAxesNames()
.indexOf(lastAnnotationPushed.selection.partition.variable);
if (axisIdxToClear !== -1) {
model.axes.getAxis(axisIdxToClear).clearSelection();
model.axes.selection = null;
}
}

const selection = model.axes.getSelection();
if (selection.type === 'empty') {
lastAnnotationPushed = AnnotationBuilder.EMPTY_ANNOTATION;
} else if (
!lastAnnotationPushed ||
model.provider.shouldCreateNewAnnotation() ||
lastAnnotationPushed.selection.type !== 'range'
) {
lastAnnotationPushed = AnnotationBuilder.annotation(
selection,
[model.defaultScore],
model.defaultWeight
);
if (lastAnnotationPushed.name === '') {
// set default range annotation name
AnnotationBuilder.setDefaultName(lastAnnotationPushed);
if (model.provider.isA('AnnotationStoreProvider')) {
lastAnnotationPushed.name = model.provider.getNextStoredAnnotationName(
lastAnnotationPushed.name
);
}
}
} else {
lastAnnotationPushed = AnnotationBuilder.update(
lastAnnotationPushed,
{
selection,
score: [model.defaultScore],
weight: model.defaultWeight,
}
);
}
AnnotationBuilder.updateReadOnlyFlag(
lastAnnotationPushed,
model.readOnlyFields
);
model.provider.setAnnotation(lastAnnotationPushed);
} else {
model.provider.setSelection(model.axes.getSelection());
}
})
);
model.subscriptions.push(
model.axes.onAxisListChange((axisPairs) => {
model.selectionDataSubscription.update(axisPairs);
})
);
} else {
model.subscriptions.push(
model.axes.onSelectionChange(() => {
publicAPI.render();
})
);
}

publicAPI.setContainer(model.container);
updateSizeInformation();

publicAPI.setNumberOfBins = (numberOfBins) => {
model.numberOfBins = numberOfBins;
if (model.selectionDataSubscription) {
model.selectionDataSubscription.update(model.axes.getAxesPairs(), {
numberOfBins,
});
}
if (model.histogram2DDataSubscription) {
model.histogram2DDataSubscription.update(model.axes.getAxesPairs(), {
numberOfBins,
});
}
};
}

// ----------------------------------------------------------------------------
// Object factory
// ----------------------------------------------------------------------------

const DEFAULT_VALUES = {
container: null,
provider: null,
needData: true,

containerHidden: false,

borderOffsetTop: 35,
borderOffsetRight: 12,
borderOffsetBottom: 45,
borderOffsetLeft: 12,

axisWidth: 6,
selectionBarWidth: 8,

polygonColors: [0, 0, 0],
selectionColors: [70, 130, 180],

maxBinCountForOpacityCalculation: 0,

selectionOpacityAdjustment: 1,
polygonOpacityAdjustment: 1,

hoverIndicatorHeight: 10,
hoverIndicatorWidth: 7,

numberOfBins: 32,

useAnnotation: false,
defaultScore: 0,
defaultWeight: 1,

showOnlySelection: false,

visibleScores: [],
propagatePartitionScores: false,
// scores: [{ name: 'Yes', color: '#00C900', value: 1 }, ...]
};

// ----------------------------------------------------------------------------

export function extend(publicAPI, model, initialValues = {}) {
Object.assign(model, DEFAULT_VALUES, initialValues);

CompositeClosureHelper.destroy(publicAPI, model);
CompositeClosureHelper.isA(publicAPI, model, 'VizComponent');
CompositeClosureHelper.get(publicAPI, model, [
'provider',
'container',
'showOnlySelection',
'visibleScores',
'propagatePartitionScores',
'numberOfBins',
]);
CompositeClosureHelper.set(publicAPI, model, [
'showOnlySelection',
'propagatePartitionScores',
]);
CompositeClosureHelper.dynamicArray(publicAPI, model, 'readOnlyFields');

parallelCoordinate(publicAPI, model);
}

// ----------------------------------------------------------------------------

export const newInstance = CompositeClosureHelper.newInstance(extend);

// ----------------------------------------------------------------------------

export default { newInstance, extend };