HistogramSelector

Source

index.js
import d3 from 'd3';
import style from 'PVWStyle/InfoVizNative/HistogramSelector.mcss';

import CompositeClosureHelper from 'paraviewweb/src/Common/Core/CompositeClosureHelper';
import multiClicker from 'paraviewweb/src/InfoViz/Core/D3MultiClick';
import score from 'paraviewweb/src/InfoViz/Native/HistogramSelector/score';

// ----------------------------------------------------------------------------
// Histogram Selector
// ----------------------------------------------------------------------------
//
// This component is designed to display histograms in a grid and support
// user selection of histograms. The idea being to allow the user to view
// histograms for a large number of parameters and then select some of
// those parameters to use in further visualizations.
//
// Due to the large number of DOM elements a histogram can have, we modify
// the standard D3 graph approach to reuse DOM elements as the histograms
// scroll offscreen. This way we can support thousands of histograms
// while only creating enough DOM elements to fill the screen.
//
// A Transform is used to reposition existing DOM elements as they
// are reused. Supposedly this is a fast operation. The idea comes from
// http://bl.ocks.org/gmaclennan/11130600 among other examples.
// Reuse happens at the row level.
//
// The minBoxSize variable controls the smallest width that a box
// (histogram) will use. This code will fill its container with the
// smallest size histogram it can that does not exceed this limit and
// provides an integral number of histograms across the container's width.
//

function histogramSelector(publicAPI, model) {
// in contact-sheet mode, specify the smallest width a histogram can shrink
// to before fewer histograms are created to fill the container's width
let minBoxSize = 200;
// smallest we'll let it go. Limits boxesPerRow in header GUI.
const minBoxSizeLimit = 125;
const legendSize = 15;
const iconSize = 24.3;
// hard coded because I did not figure out how to
// properly query this value from our container.
const borderSize = 6;
// 8? for linux/firefox, 16 for win10/chrome (hi-res screen)
const scrollbarWidth = 16;

let displayOnlySelected = false;

const scoreHelper = score(publicAPI, model);

// This function modifies the Transform property
// of the rows of the grid. Instead of creating new
// rows filled with DOM elements. Inside histogramSelector()
// to make sure document.head/body exists.
const transformCSSProp = (function tcssp(property) {
const prefixes = ['webkit', 'ms', 'Moz', 'O'];
let i = -1;
const n = prefixes.length;
let s = null;
s = document.head ? document.head.style : null || s;
s = document.body ? document.body.style : null || s;

if (s === null || property.toLowerCase() in s) {
return property.toLowerCase();
}

/* eslint-disable no-plusplus */
while (++i < n) {
if (prefixes[i] + property in s) {
return `-${prefixes[i].toLowerCase()}${property
.replace(/([A-Z])/g, '-$1')
.toLowerCase()}`;
}
}
/* eslint-enable no-plusplus */

return false;
})('Transform');

// Apply our desired attributes to the grid rows
function styleRows(selection, self) {
selection
.classed(style.row, true)
.style('height', `${self.rowHeight}px`)
.style(
transformCSSProp,
(d, i) => `translate3d(0,${d.key * self.rowHeight}px,0)`
);
}

// apply our desired attributes to the boxes of a row
function styleBoxes(selection, self) {
selection
.style('width', `${self.boxWidth}px`)
.style('height', `${self.boxHeight}px`);
// .style('margin', `${self.boxMargin / 2}px`)
}

publicAPI.svgWidth = () =>
model.histWidth + model.histMargin.left + model.histMargin.right;
publicAPI.svgHeight = () =>
model.histHeight + model.histMargin.top + model.histMargin.bottom;

function getClientArea() {
const clientRect = model.listContainer.getBoundingClientRect();
return [
clientRect.width - borderSize - scrollbarWidth,
clientRect.height - borderSize,
];
}

function updateSizeInformation(singleMode) {
let updateBoxPerRow = false;

const boxMargin = 3; // outside the box dimensions
const boxBorder = 3; // included in the box dimensions, visible border

// Get the client area size
const dimensions = getClientArea();

// compute key values based on our new size
const boxesPerRow = singleMode
? 1
: Math.max(1, Math.floor(dimensions[0] / minBoxSize));
model.boxWidth = Math.floor(dimensions[0] / boxesPerRow) - 2 * boxMargin;
if (boxesPerRow === 1) {
// use 3 / 4 to make a single hist wider than it is tall.
model.boxHeight = Math.min(
Math.floor((model.boxWidth + 2 * boxMargin) * (3 / 4) - 2 * boxMargin),
Math.floor(dimensions[1] - 2 * boxMargin)
);
} else {
model.boxHeight = model.boxWidth;
}
model.rowHeight = model.boxHeight + 2 * boxMargin;
model.rowsPerPage = Math.ceil(dimensions[1] / model.rowHeight);

if (boxesPerRow !== model.boxesPerRow) {
updateBoxPerRow = true;
model.boxesPerRow = boxesPerRow;
}

model.histWidth =
model.boxWidth -
boxBorder * 2 -
model.histMargin.left -
model.histMargin.right;
// other row size, probably a way to query for this
const otherRowHeight = 23;
model.histHeight =
model.boxHeight -
boxBorder * 2 -
otherRowHeight -
model.histMargin.top -
model.histMargin.bottom;

return updateBoxPerRow;
}

// which row of model.nest does this field name reside in?
function getFieldRow(name) {
if (model.nest === null) return 0;
const foundRow = model.nest.reduce((prev, item, i) => {
const val = item.value.filter((def) => def.name === name);
if (val.length > 0) {
return item.key;
}
return prev;
}, 0);
return foundRow;
}

function getCurrentFieldNames() {
let fieldNames = [];
// Initialize fields
if (model.provider.isA('FieldProvider')) {
fieldNames = !displayOnlySelected
? model.provider.getFieldNames()
: model.provider.getActiveFieldNames();
}
fieldNames = scoreHelper.filterFieldNames(fieldNames);
return fieldNames;
}

const fieldHeaderClick = (d) => {
displayOnlySelected = !displayOnlySelected;
publicAPI.render();
};

function incrNumBoxes(amount) {
if (model.singleModeName !== null) return;
// Get the client area size
const dimensions = getClientArea();
const maxNumBoxes = Math.floor(dimensions[0] / minBoxSizeLimit);
const newBoxesPerRow = Math.min(
maxNumBoxes,
Math.max(1, model.boxesPerRow + amount)
);

// if we actually changed, re-render, letting updateSizeInformation actually change dimensions.
if (newBoxesPerRow !== model.boxesPerRow) {
// compute a reasonable new minimum for box size based on the current container dimensions.
// Midway between desired and next larger number of boxes, except at limit.
const newMinBoxSize =
newBoxesPerRow === maxNumBoxes
? minBoxSizeLimit
: Math.floor(
0.5 *
(dimensions[0] / newBoxesPerRow +
dimensions[0] / (newBoxesPerRow + 1))
);
minBoxSize = newMinBoxSize;
publicAPI.render();
}
}

// let the caller set a specific number of boxes/row, within our normal size constraints.
publicAPI.requestNumBoxesPerRow = (count) => {
model.singleModeName = null;
model.singleModeSticky = false;
incrNumBoxes(count - model.boxesPerRow);
};

function changeSingleField(direction) {
if (model.singleModeName === null) return;
const fieldNames = getCurrentFieldNames();
if (fieldNames.length === 0) return;

let index = fieldNames.indexOf(model.singleModeName);
if (index === -1) index = 0;
else index = (index + direction) % fieldNames.length;
if (index < 0) index = fieldNames.length - 1;

model.singleModeName = fieldNames[index];
publicAPI.render();
}

function createHeader(divSel) {
const header = divSel
.append('div')
.classed(style.header, true)
.style('height', `${model.headerSize}px`)
.style('line-height', `${model.headerSize}px`);
header
.append('span')
.on('click', fieldHeaderClick)
.append('i')
.classed(style.jsFieldsIcon, true);
header
.append('span')
.classed(style.jsHeaderLabel, true)
.text('Only Selected')
.on('click', fieldHeaderClick);

scoreHelper.createHeader(header);

const numBoxesSpan = header.append('span').classed(style.headerBoxes, true);
numBoxesSpan
.append('i')
.classed(style.headerBoxesMinus, true)
.on('click', () => incrNumBoxes(-1));
numBoxesSpan
.append('span')
.classed(style.jsHeaderBoxesNum, true)
.text(model.boxesPerRow);
numBoxesSpan
.append('i')
.classed(style.headerBoxesPlus, true)
.on('click', () => incrNumBoxes(1));

const singleSpan = header.append('span').classed(style.headerSingle, true);
singleSpan
.append('i')
.classed(style.headerSinglePrev, true)
.on('click', () => changeSingleField(-1));
singleSpan
.append('span')
.classed(style.jsHeaderSingleField, true)
.text('');
singleSpan
.append('i')
.classed(style.headerSingleNext, true)
.on('click', () => changeSingleField(1));
}

function updateHeader(dataLength) {
if (model.singleModeSticky) {
// header isn't useful for a single histogram.
d3.select(model.container)
.select(`.${style.jsHeader}`)
.style('display', 'none');
return;
}
d3.select(model.container)
.select(`.${style.jsHeader}`)
.style('display', null);
d3.select(model.container)
.select(`.${style.jsFieldsIcon}`)
// apply class - 'false' should come first to not remove common base class.
.classed(
displayOnlySelected ? style.allFieldsIcon : style.selectedFieldsIcon,
false
)
.classed(
!displayOnlySelected ? style.allFieldsIcon : style.selectedFieldsIcon,
true
);
scoreHelper.updateHeader();

d3.select(model.container)
.select(`.${style.jsHeaderBoxes}`)
.style('display', model.singleModeName === null ? null : 'none');
d3.select(model.container)
.select(`.${style.jsHeaderBoxesNum}`)
.text(`${model.boxesPerRow} /row`);

d3.select(model.container)
.select(`.${style.jsHeaderSingle}`)
.style('display', model.singleModeName === null ? 'none' : null);

if (model.provider.isA('LegendProvider') && model.singleModeName) {
const { color, shape } = model.provider.getLegend(model.singleModeName);
d3.select(model.container)
.select(`.${style.jsHeaderSingleField}`)
.html(
`<svg class='${
style.legendSvg
}' width='${legendSize}' height='${legendSize}' viewBox='${
shape.viewBox
}' fill='${color}' stroke='black'><use xlink:href='#${
shape.id
}'/></svg>`
);
} else {
d3.select(model.container)
.select(`.${style.jsHeaderSingleField}`)
.text(() => {
let name = model.singleModeName;
if (!name) return '';
if (name.length > 10) {
name = `${name.slice(0, 9)}...`;
}
return name;
});
}
}

publicAPI.getMouseCoords = (tdsl) => {
// y-coordinate is not handled correctly for svg or svgGr or overlay inside scrolling container.
const coord = d3.mouse(tdsl.node());
return [coord[0] - model.histMargin.left, coord[1] - model.histMargin.top];
};

publicAPI.resize = () => {
if (!model.container) return;

const clientRect = model.container.getBoundingClientRect();
const deltaHeader = model.singleModeSticky ? 0 : model.headerSize;
if (clientRect.width !== 0 && clientRect.height > deltaHeader) {
model.containerHidden = false;
d3.select(model.listContainer).style(
'height',
`${clientRect.height - deltaHeader}px`
);
// scrollbarWidth = model.listContainer.offsetWidth - clientRect.width;
publicAPI.render();
} else {
model.containerHidden = true;
}
};

function toggleSingleModeEvt(d) {
if (!model.singleModeSticky) {
if (model.singleModeName === null) {
model.singleModeName = d.name;
} else {
model.singleModeName = null;
}
model.scrollToName = d.name;
publicAPI.render();
}
if (d3.event) d3.event.stopPropagation();
}

// Display a single histogram. If disableSwitch is true, switching to
// other histograms in the fields list is disabled.
// Calling requestNumBoxesPerRow() re-enables switching.
publicAPI.displaySingleHistogram = (fieldName, disableSwitch) => {
model.singleModeName = fieldName;
model.scrollToName = fieldName;
if (model.singleModeName && disableSwitch) {
model.singleModeSticky = true;
} else {
model.singleModeSticky = false;
}
publicAPI.resize();
};

publicAPI.disableFieldActions = (fieldName, actionNames) => {
if (!model.disabledFieldsActions) {
model.disabledFieldsActions = {};
}
if (!model.disabledFieldsActions[fieldName]) {
model.disabledFieldsActions[fieldName] = [];
}
const disableActionList = model.disabledFieldsActions[fieldName];
[].concat(actionNames).forEach((action) => {
if (disableActionList.indexOf(action) === -1) {
disableActionList.push(action);
}
});
};

publicAPI.enableFieldActions = (fieldName, actionNames) => {
if (!model.disabledFieldsActions) {
return;
}
if (!model.disabledFieldsActions[fieldName]) {
return;
}
const disableActionList = model.disabledFieldsActions[fieldName];
[].concat(actionNames).forEach((action) => {
const idx = disableActionList.indexOf(action);
if (idx !== -1) {
disableActionList.splice(idx, 1);
}
});
};

publicAPI.isFieldActionDisabled = (fieldName, actionName) => {
if (!model.disabledFieldsActions) {
return false;
}
if (!model.disabledFieldsActions[fieldName]) {
return false;
}
const disableActionList = model.disabledFieldsActions[fieldName];
return disableActionList.indexOf(actionName) !== -1;
};

publicAPI.render = (onlyFieldName = null) => {
if (
!model.fieldData ||
(onlyFieldName !== null && !model.fieldData[onlyFieldName])
) {
return;
}

if (!model.container || model.container.offsetParent === null) return;
if (!model.listContainer) return;
if (model.containerHidden) {
publicAPI.resize();
return;
}

updateSizeInformation(model.singleModeName !== null);
let fieldNames = getCurrentFieldNames();

updateHeader(fieldNames.length);
if (model.singleModeName !== null) {
// display only one histogram at a time.
fieldNames = [model.singleModeName];
}

// If we find down the road that it's too expensive to re-populate the nest
// all the time, we can try to come up with the proper guards that make sure
// we do whenever we need it, but not more. For now, we just make sure we
// always get the updates we need.
if (fieldNames.length > 0) {
// get the data and put it into the nest based on the
// number of boxesPerRow
const mungedData = fieldNames
.filter((name) => model.fieldData[name])
.map((name) => {
const d = model.fieldData[name];
return d;
});

model.nest = mungedData.reduce((prev, item, i) => {
const group = Math.floor(i / model.boxesPerRow);
if (prev[group]) {
prev[group].value.push(item);
} else {
prev.push({
key: group,
value: [item],
});
}
return prev;
}, []);
}

// resize the div area to be tall enough to hold all our
// boxes even though most are 'virtual' and lack DOM
const newHeight = `${Math.ceil(model.nest.length * model.rowHeight)}px`;
model.parameterList.style('height', newHeight);

if (!model.nest) return;

// if we've changed view modes, single <==> contact sheet,
// we need to re-scroll.
if (model.scrollToName !== null) {
const topRow = getFieldRow(model.scrollToName);
model.listContainer.scrollTop = topRow * model.rowHeight;
model.scrollToName = null;
}

// scroll distance, in pixels.
const scrollY = model.listContainer.scrollTop;
// convert scroll from pixels to rows, get one row above (-1)
const offset = Math.max(0, Math.floor(scrollY / model.rowHeight) - 1);

// extract the visible graphs from the data based on how many rows
// we have scrolled down plus one above and one below (+2)
const count = model.rowsPerPage + 2;
const dataSlice = model.nest.slice(offset, offset + count);

// attach our slice of data to the rows
const rows = model.parameterList
.selectAll('div')
.data(dataSlice, (d) => d.key);

// here is the code that reuses the exit nodes to fill entry
// nodes. If there are not enough exit nodes then additional ones
// will be created as needed. The boxes start off as hidden and
// later have the class removed when their data is ready
const exitNodes = rows.exit();
rows.enter().append(() => {
let reusableNode = 0;
for (let i = 0; i < exitNodes[0].length; i++) {
reusableNode = exitNodes[0][i];
if (reusableNode) {
exitNodes[0][i] = undefined;
d3.select(reusableNode)
.selectAll('table')
.classed(style.hiddenBox, true);
return reusableNode;
}
}
return document.createElement('div');
});
rows.call(styleRows, model);

// if there are exit rows remaining that we
// do not need we can delete them
rows.exit().remove();

// now put the data into the boxes
const boxes = rows.selectAll('table').data((d) => d.value);
boxes
.enter()
.append('table')
.classed(style.hiddenBox, true);

// free up any extra boxes
boxes.exit().remove();

// scoring interface - create floating controls to set scores, values, when needed.
scoreHelper.createPopups();

// for every item that has data, create all the sub-elements
// and size them correctly based on our data
function prepareItem(def, idx) {
// updateData is called in response to UI events; it tells
// the dataProvider to update the data to match the UI.
//
// updateData must be inside prepareItem() since it uses idx;
// d3's listener method cannot guarantee the index passed to
// updateData will be correct:
function updateData(data) {
// data.selectedGen++;
// model.provider.updateField(data.name, { active: data.selected });
model.provider.toggleFieldSelection(data.name);
}

// get existing sub elements
const ttab = d3.select(this);
let trow1 = ttab.select(`tr.${style.jsLegendRow}`);
let trow2 = ttab.select(`tr.${style.jsTr2}`);
let tdsl = trow2.select(`td.${style.jsSparkline}`);
let legendCell = trow1.select(`.${style.jsLegend}`);
let fieldCell = trow1.select(`.${style.jsFieldName}`);
let iconCell = trow1.select(`.${style.jsLegendIcons}`);
let iconCellViz = iconCell.select(`.${style.jsLegendIconsViz}`);
let svgGr = tdsl.select('svg').select(`.${style.jsGHist}`);
// let svgOverlay = svgGr.select(`.${style.jsOverlay}`);

// if they are not created yet then create them
if (trow1.empty()) {
trow1 = ttab
.append('tr')
.classed(style.legendRow, true)
.on(
'click',
multiClicker([
function singleClick(d, i) {
// single click handler
// const overCoords = d3.mouse(model.listContainer);
updateData(d);
},
// double click handler
toggleSingleModeEvt,
])
);
trow2 = ttab.append('tr').classed(style.jsTr2, true);
tdsl = trow2
.append('td')
.classed(style.sparkline, true)
.attr('colspan', '3');
legendCell = trow1.append('td').classed(style.legend, true);

fieldCell = trow1.append('td').classed(style.fieldName, true);
iconCell = trow1.append('td').classed(style.legendIcons, true);
iconCellViz = iconCell
.append('span')
.classed(style.legendIconsViz, true);
scoreHelper.createScoreIcons(iconCellViz);
iconCellViz
.append('i')
.classed(style.expandIcon, true)
.on('click', toggleSingleModeEvt);

// Create SVG, and main group created inside the margins for use by axes, title, etc.
svgGr = tdsl
.append('svg')
.classed(style.sparklineSvg, true)
.append('g')
.classed(style.jsGHist, true)
.attr(
'transform',
`translate( ${model.histMargin.left}, ${model.histMargin.top} )`
);
// nested groups inside main group
svgGr.append('g').classed(style.axis, true);
svgGr.append('g').classed(style.jsGRect, true);
// scoring interface
scoreHelper.createGroups(svgGr);
svgGr
.append('rect')
.classed(style.overlay, true)
.style('cursor', 'default');
}
const dataActive = def.active;
// Apply legend
if (model.provider.isA('LegendProvider')) {
const { color, shape } = model.provider.getLegend(def.name);
legendCell.html(`<svg class='${
style.legendSvg
}' width='${legendSize}' height='${legendSize}' viewBox='${
shape.viewBox
}'
fill='${color}' stroke='black'><use xlink:href='#${
shape.id
}'/></svg>`);
} else {
legendCell.html('<i></i>').select('i');
}
trow1
.classed(
!dataActive ? style.selectedLegendRow : style.unselectedLegendRow,
false
)
.classed(
dataActive ? style.selectedLegendRow : style.unselectedLegendRow,
true
);
// selection outline
ttab
.classed(style.hiddenBox, false)
.classed(!dataActive ? style.selectedBox : style.unselectedBox, false)
.classed(dataActive ? style.selectedBox : style.unselectedBox, true);

// Change interaction icons based on state.
// scoreHelper has save icon and score icon.
const numIcons =
(model.singleModeSticky ? 0 : 1) + scoreHelper.numScoreIcons(def);
iconCell.style('width', `${numIcons * iconSize + 2}px`);
scoreHelper.updateScoreIcons(iconCellViz, def);
iconCellViz
.select(`.${style.jsExpandIcon}`)
.attr(
'class',
model.singleModeName === null ? style.expandIcon : style.shrinkIcon
)
.style('display', model.singleModeSticky ? 'none' : null);
// + 2 accounts for internal padding.
const allIconsWidth =
Math.ceil(iconCellViz.node().getBoundingClientRect().width) + 2;
// reset to the actual width used.
iconCell.style('width', `${allIconsWidth}px`);
// Apply field name
fieldCell
.style(
'width',
`${model.boxWidth - (10 + legendSize + 6 + allIconsWidth)}px`
)
.text(def.name);

// adjust some settings based on current size
tdsl
.select('svg')
.attr('width', publicAPI.svgWidth())
.attr('height', publicAPI.svgHeight());

// get the histogram data and rebuild the histogram based on the results
const hobj = def.hobj;
if (hobj) {
const cmax = 1.0 * d3.max(hobj.counts);
const hsize = hobj.counts.length;
const hdata = svgGr
.select(`.${style.jsGRect}`)
.selectAll(`.${style.jsHistRect}`)
.data(hobj.counts);

hdata.enter().append('rect');
// changes apply to both enter and update data join:
hdata
.attr(
'class',
(d, i) => (i % 2 === 0 ? style.histRectEven : style.histRectOdd)
)
.attr('pname', def.name)
.attr('y', (d) => model.histHeight * (1.0 - d / cmax))
.attr('x', (d, i) => (model.histWidth / hsize) * i)
.attr('height', (d) => model.histHeight * (d / cmax))
.attr('width', Math.ceil(model.histWidth / hsize));

hdata.exit().remove();

const svgOverlay = svgGr.select(`.${style.jsOverlay}`);
svgOverlay
.attr('x', -model.histMargin.left)
.attr('y', -model.histMargin.top)
.attr('width', publicAPI.svgWidth())
.attr('height', publicAPI.svgHeight()); // allow clicks inside x-axis.

if (!scoreHelper.editingScore(def)) {
if (model.provider.isA('HistogramBinHoverProvider')) {
svgOverlay
.on('mousemove.hs', (d, i) => {
const mCoords = publicAPI.getMouseCoords(tdsl);
const binNum = Math.floor(
(mCoords[0] / model.histWidth) * hsize
);
const state = {};
state[def.name] = [binNum];
model.provider.setHoverState({ state });
})
.on('mouseout.hs', (d, i) => {
const state = {};
state[def.name] = [-1];
model.provider.setHoverState({ state });
});
}
svgOverlay.on('click.hs', (d) => {
const overCoords = publicAPI.getMouseCoords(tdsl);
if (overCoords[1] <= model.histHeight) {
updateData(d);
}
});
} else {
// disable when score editing is happening - it's distracting.
// Note we still respond to hovers over other components.
svgOverlay.on('.hs', null);
}

// Show an x-axis with just min/max displayed.
// Attach scale, axis objects to this box's
// data (the 'def' object) to allow persistence when scrolled.
if (typeof def.xScale === 'undefined') {
def.xScale = d3.scale.linear();
}
const [minRange, maxRange] = scoreHelper.getHistRange(def);
def.xScale
.rangeRound([0, model.histWidth])
.domain([minRange, maxRange]);

if (typeof def.xAxis === 'undefined') {
const formatter = d3.format('.3s');
def.xAxis = d3.svg
.axis()
.tickFormat(formatter)
.orient('bottom');
}
def.xAxis.scale(def.xScale);
let numTicks = 2;
if (model.histWidth >= model.moreTicksSize) {
numTicks = 5;
// using .ticks() results in skipping min/max values,
// if they aren't 'nice'. Make exactly 5 ticks.
const myTicks = d3
.range(numTicks)
.map(
(d) => minRange + (d / (numTicks - 1)) * (maxRange - minRange)
);
def.xAxis.tickValues(myTicks);
} else {
def.xAxis.tickValues(def.xScale.domain());
}
// nested group for the x-axis min/max display.
const gAxis = svgGr.select(`.${style.jsAxis}`);
gAxis
.attr('transform', `translate(0, ${model.histHeight})`)
.call(def.xAxis);
const tickLabels = gAxis
.selectAll('text')
.classed(style.axisText, true);
numTicks = tickLabels.size();
tickLabels.style(
'text-anchor',
(d, i) => (i === 0 ? 'start' : i === numTicks - 1 ? 'end' : 'middle')
);
gAxis.selectAll('line').classed(style.axisLine, true);
gAxis.selectAll('path').classed(style.axisPath, true);

scoreHelper.prepareItem(def, idx, svgGr, tdsl);
}
}

// make sure all the elements are created
// and updated
if (onlyFieldName === null) {
boxes.each(prepareItem);
boxes.call(styleBoxes, model);
} else {
boxes.filter((def) => def.name === onlyFieldName).each(prepareItem);
}
};

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) {
const cSel = d3.select(model.container);
createHeader(cSel);
// wrapper height is set insize resize()
const wrapper = cSel
.append('div')
.style('overflow-y', 'auto')
.style('overflow-x', 'hidden')
.on('scroll', () => {
publicAPI.render();
});

model.listContainer = wrapper.node();
model.parameterList = wrapper
.append('div')
.classed(style.histogramSelector, true);

model.parameterList
.append('span')
.classed(style.parameterScrollFix, true);
publicAPI.resize();

setImmediate(scoreHelper.updateFieldAnnotations);
}
};

function handleHoverUpdate(data) {
const everything = d3.select(model.container);
Object.keys(data.state).forEach((pName) => {
const binList = data.state[pName];
everything
.selectAll(`rect[pname='${pName}']`)
.classed(style.binHilite, (d, i) => binList.indexOf(i) >= 0);
});
}

function createFieldData(fieldName) {
return Object.assign(
model.fieldData[fieldName] || {},
model.provider.getField(fieldName),
scoreHelper.defaultFieldData()
);
}

// Auto unmount on destroy
model.subscriptions.push({ unsubscribe: publicAPI.setContainer });

if (model.provider.isA('FieldProvider')) {
if (!model.fieldData) {
model.fieldData = {};
}

model.provider.getFieldNames().forEach((name) => {
model.fieldData[name] = createFieldData(name);
});

model.subscriptions.push(
model.provider.onFieldChange((field) => {
if (field && model.fieldData[field.name]) {
Object.assign(model.fieldData[field.name], field);
publicAPI.render();
} else {
const fieldNames = model.provider.getFieldNames();
if (field) {
model.fieldData[field.name] = createFieldData(field.name);
} else {
// check for deleted field. Delete our fieldData if so. Ensures subscription remains up-to-date.
Object.keys(model.fieldData).forEach((name) => {
if (fieldNames.indexOf(name) === -1) {
delete model.fieldData[name];
}
});
}
model.histogram1DDataSubscription.update(fieldNames, {
numberOfBins: model.numberOfBins,
partial: true,
});
}
})
);
}

if (model.provider.isA('HistogramBinHoverProvider')) {
model.subscriptions.push(
model.provider.onHoverBinChange(handleHoverUpdate)
);
}

if (model.provider.isA('Histogram1DProvider')) {
model.histogram1DDataSubscription = model.provider.subscribeToHistogram1D(
(data) => {
// Below, we're asking for partial updates, so we just update our
// cache with anything that came in.
Object.keys(data).forEach((name) => {
if (!model.fieldData[name]) model.fieldData[name] = {};
if (model.fieldData[name].hobj) {
const oldRangeMin = model.fieldData[name].hobj.min;
const oldRangeMax = model.fieldData[name].hobj.max;
model.fieldData[name].hobj = data[name];
scoreHelper.rescaleDividers(name, oldRangeMin, oldRangeMax);
} else {
model.fieldData[name].hobj = data[name];
}
});

publicAPI.render();
},
Object.keys(model.fieldData),
{
numberOfBins: model.numberOfBins,
partial: true,
}
);

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

if (model.provider.isA('AnnotationStoreProvider')) {
// Preload annotation from store
const partitionSelectionToLoad = {};
const annotations = model.provider.getStoredAnnotations();
Object.keys(annotations).forEach((id) => {
const annotation = annotations[id];
if (annotation && annotation.selection.type === 'partition') {
partitionSelectionToLoad[
annotation.selection.partition.variable
] = annotation;
}
});
if (Object.keys(partitionSelectionToLoad).length) {
scoreHelper.updateFieldAnnotations(partitionSelectionToLoad);
}

model.subscriptions.push(
model.provider.onStoreAnnotationChange((event) => {
if (event.action === 'delete' && event.annotation) {
const annotation = event.annotation;
if (annotation.selection.type === 'partition') {
const fieldName = annotation.selection.partition.variable;
if (model.fieldData[fieldName]) {
scoreHelper.clearFieldAnnotation(fieldName);
publicAPI.render(fieldName);
}
}
}
})
);
}

// scoring interface
scoreHelper.addSubscriptions();

// Make sure default values get applied
publicAPI.setContainer(model.container);

// Expose update fields partitions
publicAPI.updateFieldAnnotations = scoreHelper.updateFieldAnnotations;

publicAPI.getAnnotationForField = (fieldName) =>
model.fieldData[fieldName].annotation;
}

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

const DEFAULT_VALUES = {
container: null,
provider: null,
listContainer: null,
needData: true,
containerHidden: false,

parameterList: null,
nest: null, // nested aray of data nest[rows][boxes]
boxesPerRow: 0,
rowsPerPage: 0,
boxWidth: 120,
boxHeight: 120,
// show 1 per row?
singleModeName: null,
singleModeSticky: false,
scrollToName: null,
// margins inside the SVG element.
histMargin: { top: 6, right: 12, bottom: 23, left: 12 },
histWidth: 90,
histHeight: 70,
// what's the smallest histogram size that shows 5 ticks, instead of min/max? in pixels
moreTicksSize: 300,
lastOffset: -1,
headerSize: 25,
// scoring interface activated by passing in 'scores' array externally.
// scores: [{ name: 'Yes', color: '#00C900' }, ... ],
defaultScore: 0,
dragMargin: 8,
selectedDef: null,

numberOfBins: 32,
// display UI for setting uncertainty at dividers.
showUncertainty: true,
};

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

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',
'numberOfBins',
'showUncertainty',
]);
CompositeClosureHelper.set(publicAPI, model, [
'numberOfBins',
'showUncertainty',
]);
CompositeClosureHelper.dynamicArray(publicAPI, model, 'readOnlyFields');

histogramSelector(publicAPI, model);
}

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

export const newInstance = CompositeClosureHelper.newInstance(extend);

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

export default { newInstance, extend };
score.js
import d3 from 'd3';
import deepEquals from 'mout/src/lang/deepEquals';

import style from 'PVWStyle/InfoVizNative/HistogramSelector.mcss';

import SelectionBuilder from 'paraviewweb/src/Common/Misc/SelectionBuilder';
import AnnotationBuilder from 'paraviewweb/src/Common/Misc/AnnotationBuilder';

// import downArrowImage from './down_arrow.png';

export default function init(inPublicAPI, inModel) {
const publicAPI = inPublicAPI;
const model = inModel;
let displayOnlyScored = false;
let scorePopupDiv = null;
let dividerPopupDiv = null;
let dividerValuePopupDiv = null;

publicAPI.setScores = (scores, defaultScore) => {
// TODO make sure model.scores has the right format?
model.scores = scores;
model.defaultScore = defaultScore;
if (model.scores) {
// setup a bgColor
model.scores.forEach((score, i) => {
const lightness = d3.hsl(score.color).l;
// make bg darker for light colors.
const blend = lightness >= 0.45 ? 0.4 : 0.2;
const interp = d3.interpolateRgb('#fff', score.color);
score.bgColor = interp(blend);
});
}
};

function enabled() {
return model.scores !== undefined;
}

if (model.provider.isA('ScoresProvider')) {
publicAPI.setScores(
model.provider.getScores(),
model.provider.getDefaultScore()
);
}

function defaultFieldData() {
return {
annotation: null,
};
}

function createDefaultDivider(val, uncert) {
return {
value: val,
uncertainty: uncert,
};
}

function getHistRange(def) {
let minRange = def.range[0];
let maxRange = def.range[1];
if (def.hobj) {
minRange = def.hobj.min;
maxRange = def.hobj.max;
}
if (minRange === maxRange) maxRange += 1;
return [minRange, maxRange];
}
// add implicit bounds for the histogram min/max to dividers list
function getRegionBounds(def) {
const [minRange, maxRange] = getHistRange(def);
return [minRange].concat(def.dividers.map((div) => div.value), maxRange);
}

function getUncertScale(def) {
// handle a zero range (like from the cumulative score histogram)
// const [minRange, maxRange] = getHistRange(def);
// return (maxRange - minRange);

// We are not going to scale uncertainty - use values in
// the same units as the histogram itself
return 1.0;
}

// Translate our dividers and regions into an annotation
// suitable for scoring this histogram.
function dividersToPartition(def, scores) {
if (!def.regions || !def.dividers || !scores) return null;
if (def.regions.length !== def.dividers.length + 1) return null;
const uncertScale = getUncertScale(def);

const partitionSelection = SelectionBuilder.partition(
def.name,
def.dividers
);
partitionSelection.partition.dividers.forEach((div, index) => {
div.uncertainty *= uncertScale;
});
// console.log('DBG partitionSelection', JSON.stringify(partitionSelection, 2));

// Construct a partition annotation:
let partitionAnnotation = null;
if (def.annotation && !model.provider.shouldCreateNewAnnotation()) {
// don't send a new selection unless it's changed.
const saveGen = partitionSelection.generation;
partitionSelection.generation = def.annotation.selection.generation;
const changeSet = { score: def.regions };
if (!deepEquals(partitionSelection, def.annotation.selection)) {
partitionSelection.generation = saveGen;
changeSet.selection = partitionSelection;
}
partitionAnnotation = AnnotationBuilder.update(def.annotation, {
selection: partitionSelection,
score: def.regions,
});
} else {
partitionAnnotation = AnnotationBuilder.annotation(
partitionSelection,
def.regions,
1,
''
);
}
AnnotationBuilder.updateReadOnlyFlag(
partitionAnnotation,
model.readOnlyFields
);
return partitionAnnotation;
}

// retrieve annotation, and re-create dividers and regions
function partitionToDividers(scoreData, def, scores) {
// console.log('DBG return', JSON.stringify(scoreData, null, 2));
const uncertScale = getUncertScale(def);
const regions = scoreData.score;
const dividers = JSON.parse(
JSON.stringify(scoreData.selection.partition.dividers)
);
dividers.forEach((div, index) => {
div.uncertainty *= 1 / uncertScale;
});

// don't replace the default region with an empty region, so UI can display the default region.
if (
regions.length > 0 &&
!(regions.length === 1 && regions[0] === model.defaultScore)
) {
def.regions = [].concat(regions);
def.dividers = dividers;
}
}

// communicate with the server which regions/dividers have changed.
function sendScores(def, passive = false) {
const scoreData = dividersToPartition(def, model.scores);
if (scoreData === null) {
console.error('Cannot translate scores to send to provider');
return;
}
if (model.provider.isA('SelectionProvider')) {
if (!scoreData.name) {
AnnotationBuilder.setDefaultName(scoreData);
if (model.provider.isA('AnnotationStoreProvider')) {
scoreData.name = model.provider.getNextStoredAnnotationName(
scoreData.name
);
}
}
if (!passive) {
model.provider.setAnnotation(scoreData);
} else if (
model.provider.isA('AnnotationStoreProvider') &&
model.provider.getStoredAnnotation(scoreData.id)
) {
// Passive means we don't want to set the active annotation, but if there is
// a stored annotation matching these score dividers, we still need to update
// that stored annotation
model.provider.updateStoredAnnotations({
[scoreData.id]: scoreData,
});
}
}
}

function showScore(def) {
// show the regions when: editing, or when they are non-default. CSS rule makes visible on hover.
return (
def.editScore ||
(typeof def.regions !== 'undefined' &&
(def.regions.length > 1 || def.regions[0] !== model.defaultScore))
);
}

function setEditScore(def, newEditScore) {
def.editScore = newEditScore;
// set existing annotation as current if we activate for editing
if (def.editScore && showScore(def) && def.annotation) {
// TODO special 'active' method to call, instead of an edit?
sendScores(def);
}
publicAPI.render(def.name);
// if one histogram is being edited, the others should be inactive.
if (newEditScore) {
Object.keys(model.fieldData).forEach((key) => {
const d = model.fieldData[key];
if (d !== def) {
if (d.editScore) {
d.editScore = false;
publicAPI.render(d.name);
}
}
});
}
}

publicAPI.setDefaultScorePartition = (fieldName) => {
const def = model.fieldData[fieldName];
if (!def) return;
// possibly the best we can do - check for a threshold-like annotation
if (
!def.lockAnnot ||
!(
def.regions &&
def.regions.length === 2 &&
def.regions[0] === 0 &&
def.regions[1] === 2
)
) {
// create a divider halfway through.
const [minRange, maxRange] = getHistRange(def);
def.dividers = [createDefaultDivider(0.5 * (minRange + maxRange), 0)];
// set regions to 'no' | 'yes'
def.regions = [0, 2];
// clear any existing (local) annotation
def.annotation = null;
// set mode that prevents editing the annotation, except for the single divider.
def.lockAnnot = true;
sendScores(def);
}
// we might already have threshold annot, but need to score it.
setEditScore(def, true);
};

publicAPI.getScoreThreshold = (fieldName) => {
const def = model.fieldData[fieldName];
if (!def.lockAnnot)
console.log('Wrong mode for score threshold, arbitrary results.');
return def.dividers[0].value;
};

const scoredHeaderClick = (d) => {
displayOnlyScored = !displayOnlyScored;
publicAPI.render();
};

function getDisplayOnlyScored() {
return displayOnlyScored;
}

function numScoreIcons(def) {
if (!enabled()) return 0;
let count = 0;
if (
model.provider.getStoredAnnotation &&
!publicAPI.isFieldActionDisabled(def.name, 'save')
) {
count += 1;
}
if (!publicAPI.isFieldActionDisabled(def.name, 'score')) {
count += 1;
}
return count;
}

function annotationSameAsStored(annotation) {
const storedAnnot = model.provider.getStoredAnnotation(annotation.id);
if (!storedAnnot) return false;
if (annotation.generation === storedAnnot.generation) return true;
const savedGen = annotation.generation;
annotation.generation = storedAnnot.generation;
const ret = deepEquals(annotation, storedAnnot);
annotation.generation = savedGen;
return ret;
}

function createScoreIcons(iconCell) {
if (!enabled()) return;
// create/save partition annotation
if (model.provider.getStoredAnnotation) {
iconCell
.append('i')
.classed(style.noSaveIcon, true)
.on('click', (d) => {
if (model.provider.getStoredAnnotation) {
const annotation = d.annotation;
const isSame = annotationSameAsStored(annotation);
if (!isSame) {
model.provider.setStoredAnnotation(annotation.id, annotation);
} else {
model.provider.setAnnotation(annotation);
}
publicAPI.render(d.name);
if (d3.event) d3.event.stopPropagation();
}
});
}

// start/stop scoring
iconCell
.append('i')
.classed(style.scoreStartIcon, true)
.on('click', (d) => {
setEditScore(d, !d.editScore);
if (d3.event) d3.event.stopPropagation();
});
}

function updateScoreIcons(iconCell, def) {
if (!enabled()) return;

if (model.provider.getStoredAnnotation) {
// new/modified/unmodified annotation...
if (def.annotation) {
if (model.provider.getStoredAnnotation(def.annotation.id)) {
const isSame = annotationSameAsStored(def.annotation);
if (isSame) {
const isActive = def.annotation === model.provider.getAnnotation();
iconCell
.select(`.${style.jsSaveIcon}`)
.attr(
'class',
isActive
? style.unchangedActiveSaveIcon
: style.unchangedSaveIcon
);
} else {
iconCell
.select(`.${style.jsSaveIcon}`)
.attr('class', style.modifiedSaveIcon);
}
} else {
iconCell
.select(`.${style.jsSaveIcon}`)
.attr('class', style.newSaveIcon);
}
} else {
iconCell.select(`.${style.jsSaveIcon}`).attr('class', style.noSaveIcon);
}
}

iconCell
.select(`.${style.jsScoreIcon}`)
.attr('class', def.editScore ? style.scoreEndIcon : style.scoreStartIcon);

// Override icon if disabled
if (publicAPI.isFieldActionDisabled(def.name, 'save')) {
iconCell.select(`.${style.jsSaveIcon}`).attr('class', style.noSaveIcon);
}

if (publicAPI.isFieldActionDisabled(def.name, 'score')) {
iconCell
.select(`.${style.jsScoreIcon}`)
.attr('class', style.hideScoreIcon);
}
}

function createGroups(svgGr) {
// scoring interface background group, must be behind.
svgGr.insert('g', ':first-child').classed(style.jsScoreBackground, true);
svgGr.append('g').classed(style.score, true);
}

function createHeader(header) {
if (enabled()) {
header
.append('span')
.on('click', scoredHeaderClick)
.append('i')
.classed(style.jsShowScoredIcon, true);
header
.append('span')
.classed(style.jsScoredHeader, true)
.text('Only Scored')
.on('click', scoredHeaderClick);
}
}

function updateHeader() {
if (enabled()) {
d3.select(model.container)
.select(`.${style.jsShowScoredIcon}`)
// apply class - 'false' should come first to not remove common base class.
.classed(
getDisplayOnlyScored() ? style.allScoredIcon : style.onlyScoredIcon,
false
)
.classed(
!getDisplayOnlyScored() ? style.allScoredIcon : style.onlyScoredIcon,
true
);
}
}

function createDragDivider(hitIndex, val, def, hobj) {
let dragD = null;
if (hitIndex >= 0) {
// start modifying existing divider
// it becomes a temporary copy if we go outside our bounds
dragD = {
index: hitIndex,
newDivider: createDefaultDivider(
undefined,
def.dividers[hitIndex].uncertainty
),
savedUncert: def.dividers[hitIndex].uncertainty,
low: hitIndex === 0 ? hobj.min : def.dividers[hitIndex - 1].value,
high:
hitIndex === def.dividers.length - 1
? hobj.max
: def.dividers[hitIndex + 1].value,
};
} else {
// create a temp divider to render.
dragD = {
index: -1,
newDivider: createDefaultDivider(val, 0),
savedUncert: 0,
low: hobj.min,
high: hobj.max,
};
}
return dragD;
}

// enforce that divider uncertainties can't overlap.
// Look at neighboring dividers for boundaries on this divider's uncertainty.
function clampDividerUncertainty(val, def, hitIndex, currentUncertainty) {
if (hitIndex < 0) return currentUncertainty;
const [minRange, maxRange] = getHistRange(def);
let maxUncertainty = 0.5 * (maxRange - minRange);
const uncertScale = getUncertScale(def);
// Note comparison with low/high divider is signed. If val indicates divider has been
// moved _past_ the neighboring divider, low/high will be negative.
if (hitIndex > 0) {
const low =
def.dividers[hitIndex - 1].value +
def.dividers[hitIndex - 1].uncertainty * uncertScale;
maxUncertainty = Math.min(maxUncertainty, (val - low) / uncertScale);
}
if (hitIndex < def.dividers.length - 1) {
const high =
def.dividers[hitIndex + 1].value -
def.dividers[hitIndex + 1].uncertainty * uncertScale;
maxUncertainty = Math.min((high - val) / uncertScale, maxUncertainty);
}
// make sure uncertainty is zero when val has passed a neighbor.
maxUncertainty = Math.max(maxUncertainty, 0);
return Math.min(maxUncertainty, currentUncertainty);
}

// clamp the drag divider specifically
function clampDragDividerUncertainty(val, def) {
if (def.dragDivider.index < 0) return;

def.dragDivider.newDivider.uncertainty = clampDividerUncertainty(
val,
def,
def.dragDivider.index,
def.dragDivider.savedUncert
);
def.dividers[def.dragDivider.index].uncertainty =
def.dragDivider.newDivider.uncertainty;
}

function moveDragDivider(val, def) {
if (!def.dragDivider) return;
if (def.dragDivider.index >= 0) {
// if we drag outside our bounds, make this a 'temporary' extra divider.
if (val < def.dragDivider.low) {
def.dragDivider.newDivider.value = val;
def.dividers[def.dragDivider.index].value = def.dragDivider.low;
clampDragDividerUncertainty(val, def);
def.dividers[def.dragDivider.index].uncertainty = 0;
} else if (val > def.dragDivider.high) {
def.dragDivider.newDivider.value = val;
def.dividers[def.dragDivider.index].value = def.dragDivider.high;
clampDragDividerUncertainty(val, def);
def.dividers[def.dragDivider.index].uncertainty = 0;
} else {
def.dividers[def.dragDivider.index].value = val;
clampDragDividerUncertainty(val, def);
def.dragDivider.newDivider.value = undefined;
}
} else {
def.dragDivider.newDivider.value = val;
}
}

// create a sorting helper method, to sort dividers based on div.value
// D3 bug: just an accessor didn't work - must use comparator function
const bisectDividers = d3.bisector((a, b) => a.value - b.value).left;

// where are we (to the left of) in the divider list?
// Did we hit one?
function dividerPick(overCoords, def, marginPx, minVal) {
const val = def.xScale.invert(overCoords[0]);
const index = bisectDividers(def.dividers, createDefaultDivider(val));
let hitIndex = -1;
if (def.dividers.length > 0) {
if (index === 0) {
hitIndex = 0;
} else if (index === def.dividers.length) {
hitIndex = index - 1;
} else {
hitIndex =
def.dividers[index].value - val < val - def.dividers[index - 1].value
? index
: index - 1;
}
const margin = def.xScale.invert(marginPx) - minVal;
// don't pick a divider outside the bounds of the histogram - pick the last region.
if (
Math.abs(def.dividers[hitIndex].value - val) > margin ||
val < def.hobj.min ||
val > def.hobj.max
) {
// we weren't close enough...
hitIndex = -1;
}
}
return [val, index, hitIndex];
}

function regionPick(overCoords, def, hobj) {
if (def.dividers.length === 0 || def.regions.length <= 1) return 0;
const val = def.xScale.invert(overCoords[0]);
const hitIndex = bisectDividers(def.dividers, createDefaultDivider(val));
return hitIndex;
}

function finishDivider(def, hobj, forceDelete = false) {
if (!def.dragDivider) return;
const val = def.dragDivider.newDivider.value;
// if val is defined, we moved an existing divider inside
// its region, and we just need to render. Otherwise...
if (val !== undefined || forceDelete) {
// drag 30 pixels out of the hist to delete.
const dragOut = def.xScale.invert(30) - hobj.min;
if (
!def.lockAnnot &&
(forceDelete || val < hobj.min - dragOut || val > hobj.max + dragOut)
) {
if (def.dragDivider.index >= 0) {
// delete a region.
if (
forceDelete ||
def.dividers[def.dragDivider.index].value === def.dragDivider.high
) {
def.regions.splice(def.dragDivider.index + 1, 1);
} else {
def.regions.splice(def.dragDivider.index, 1);
}
// delete the divider.
def.dividers.splice(def.dragDivider.index, 1);
}
} else {
// adding a divider, we make a new region.
let replaceRegion = true;
// if we moved a divider, delete the old region, unless it's one of the edge regions - those persist.
if (def.dragDivider.index >= 0) {
if (
def.dividers[def.dragDivider.index].value === def.dragDivider.low &&
def.dragDivider.low !== hobj.min
) {
def.regions.splice(def.dragDivider.index, 1);
} else if (
def.dividers[def.dragDivider.index].value ===
def.dragDivider.high &&
def.dragDivider.high !== hobj.max
) {
def.regions.splice(def.dragDivider.index + 1, 1);
} else {
replaceRegion = false;
}
// delete the old divider
def.dividers.splice(def.dragDivider.index, 1);
}
// add a new divider
def.dragDivider.newDivider.value = Math.min(
hobj.max,
Math.max(hobj.min, val)
);
// find the index based on dividers sorted by divider.value
const index = bisectDividers(def.dividers, def.dragDivider.newDivider);
def.dividers.splice(index, 0, def.dragDivider.newDivider);
// add a new region if needed, copies the score of existing region.
if (replaceRegion) {
def.regions.splice(index, 0, def.regions[index]);
}
}
} else if (
def.dragDivider.index >= 0 &&
def.dividers[def.dragDivider.index].uncertainty !==
def.dragDivider.newDivider.uncertainty
) {
def.dividers[def.dragDivider.index].uncertainty =
def.dragDivider.newDivider.uncertainty;
}
// make sure uncertainties don't overlap.
def.dividers.forEach((divider, index) => {
divider.uncertainty = clampDividerUncertainty(
divider.value,
def,
index,
divider.uncertainty
);
});
sendScores(def);
def.dragDivider = undefined;
}

function positionPopup(popupDiv, left, top) {
const clientRect = model.listContainer.getBoundingClientRect();
const popupRect = popupDiv.node().getBoundingClientRect();
if (popupRect.width + left > clientRect.width) {
popupDiv.style('left', 'auto');
popupDiv.style('right', 0);
} else {
popupDiv.style('right', null);
popupDiv.style('left', `${left}px`);
}

if (popupRect.height + top > clientRect.height) {
popupDiv.style('top', 'auto');
popupDiv.style('bottom', 0);
} else {
popupDiv.style('bottom', null);
popupDiv.style('top', `${top}px`);
}
}

function validateDividerVal(n) {
// is it a finite float number?
return !Number.isNaN(Number(n)) && Number.isFinite(Number(n));
}

function showDividerPopup(dPopupDiv, selectedDef, hobj, coord) {
const topMargin = 4;
const rowHeight = 28;
// 's' SI unit label won't work for a number entry field.
const formatter = d3.format('.4g');
const uncertDispScale = 1; // was 100 for uncertainty as a %.

dPopupDiv.style('display', null);
positionPopup(
dPopupDiv,
coord[0] - topMargin - 0.5 * rowHeight,
coord[1] + model.headerSize - (topMargin + 2 * rowHeight)
);

const selDivider = selectedDef.dividers[selectedDef.dragDivider.index];
let savedVal = selDivider.value;
selectedDef.dragDivider.savedUncert = selDivider.uncertainty;
dPopupDiv.on('mouseleave', () => {
if (selectedDef.dragDivider) {
moveDragDivider(savedVal, selectedDef);
finishDivider(selectedDef, hobj);
}
dPopupDiv.style('display', 'none');
selectedDef.dragDivider = undefined;
publicAPI.render(selectedDef.name);
});
const uncertInput = dPopupDiv.select(`.${style.jsDividerUncertaintyInput}`);
const valInput = dPopupDiv
.select(`.${style.jsDividerValueInput}`)
.attr('value', formatter(selDivider.value))
.property('value', formatter(selDivider.value))
.on('input', () => {
// typing values, show feedback.
let val = d3.event.target.value;
if (!validateDividerVal(val)) val = savedVal;
moveDragDivider(val, selectedDef);
uncertInput.property(
'value',
formatter(
uncertDispScale * selectedDef.dragDivider.newDivider.uncertainty
)
);
publicAPI.render(selectedDef.name);
})
.on('change', () => {
// committed to a value, show feedback.
let val = d3.event.target.value;
if (!validateDividerVal(val)) val = savedVal;
else {
val = Math.min(hobj.max, Math.max(hobj.min, val));
d3.event.target.value = val;
savedVal = val;
}
moveDragDivider(val, selectedDef);
publicAPI.render(selectedDef.name);
})
.on('keyup', () => {
// revert to last committed value
if (d3.event.key === 'Escape') {
moveDragDivider(savedVal, selectedDef);
dPopupDiv.on('mouseleave')();
} else if (d3.event.key === 'Enter' || d3.event.key === 'Return') {
if (selectedDef.dragDivider) {
savedVal =
selectedDef.dragDivider.newDivider.value === undefined
? selectedDef.dividers[selectedDef.dragDivider.index].value
: selectedDef.dragDivider.newDivider.value;
}
// commit current value
dPopupDiv.on('mouseleave')();
}
});
// initial select/focus so use can immediately change the value.
valInput.node().select();
valInput.node().focus();

uncertInput
.attr('value', formatter(uncertDispScale * selDivider.uncertainty))
.property('value', formatter(uncertDispScale * selDivider.uncertainty))
.on('input', () => {
// typing values, show feedback.
let uncert = d3.event.target.value;
if (!validateDividerVal(uncert)) {
if (selectedDef.dragDivider)
uncert = selectedDef.dragDivider.savedUncert;
} else {
uncert /= uncertDispScale;
}
if (selectedDef.dragDivider) {
selectedDef.dragDivider.newDivider.uncertainty = uncert;
if (selectedDef.dragDivider.newDivider.value === undefined) {
// don't use selDivider, might be out-of-date if the server sent us dividers.
selectedDef.dividers[
selectedDef.dragDivider.index
].uncertainty = uncert;
}
}
publicAPI.render(selectedDef.name);
})
.on('change', () => {
// committed to a value, show feedback.
let uncert = d3.event.target.value;
if (!validateDividerVal(uncert)) {
if (selectedDef.dragDivider)
uncert = selectedDef.dragDivider.savedUncert;
} else {
// uncertainty is a % between 0 and 0.5
// uncert = Math.min(0.5, Math.max(0, uncert / uncertDispScale));
const [minRange, maxRange] = getHistRange(selectedDef);
uncert = Math.min(
0.5 * (maxRange - minRange),
Math.max(0, uncert / uncertDispScale)
);
d3.event.target.value = formatter(uncertDispScale * uncert);
if (selectedDef.dragDivider)
selectedDef.dragDivider.savedUncert = uncert;
}
if (selectedDef.dragDivider) {
selectedDef.dragDivider.newDivider.uncertainty = uncert;
if (selectedDef.dragDivider.newDivider.value === undefined) {
selectedDef.dividers[
selectedDef.dragDivider.index
].uncertainty = uncert;
}
}
publicAPI.render(selectedDef.name);
})
.on('keyup', () => {
if (d3.event.key === 'Escape') {
if (selectedDef.dragDivider) {
selectedDef.dragDivider.newDivider.uncertainty =
selectedDef.dragDivider.savedUncert;
}
dPopupDiv.on('mouseleave')();
} else if (d3.event.key === 'Enter' || d3.event.key === 'Return') {
if (selectedDef.dragDivider) {
selectedDef.dragDivider.savedUncert =
selectedDef.dragDivider.newDivider.uncertainty;
}
dPopupDiv.on('mouseleave')();
}
})
.on('blur', () => {
if (selectedDef.dragDivider) {
const val =
selectedDef.dragDivider.newDivider.value === undefined
? selectedDef.dividers[selectedDef.dragDivider.index].value
: selectedDef.dragDivider.newDivider.value;
clampDragDividerUncertainty(val, selectedDef);
d3.event.target.value = formatter(
uncertDispScale * selectedDef.dragDivider.newDivider.uncertainty
);
}
publicAPI.render(selectedDef.name);
});
}

function showDividerValuePopup(dPopupDiv, selectedDef, hobj, coord) {
const topMargin = 4;
const rowHeight = 28;
// 's' SI unit label won't work for a number entry field.
const formatter = d3.format('.4g');

dPopupDiv.style('display', null);
positionPopup(
dPopupDiv,
coord[0] - topMargin - 0.5 * rowHeight,
coord[1] + model.headerSize - (topMargin + 0.5 * rowHeight)
);

const selDivider = selectedDef.dividers[selectedDef.dragDivider.index];
let savedVal = selDivider.value;
selectedDef.dragDivider.savedUncert = selDivider.uncertainty;
dPopupDiv.on('mouseleave', () => {
if (selectedDef.dragDivider) {
moveDragDivider(savedVal, selectedDef);
finishDivider(selectedDef, hobj);
}
dPopupDiv.style('display', 'none');
selectedDef.dragDivider = undefined;
publicAPI.render();
});
const valInput = dPopupDiv
.select(`.${style.jsDividerValueInput}`)
.attr('value', formatter(selDivider.value))
.property('value', formatter(selDivider.value))
.on('input', () => {
// typing values, show feedback.
let val = d3.event.target.value;
if (!validateDividerVal(val)) val = savedVal;
moveDragDivider(val, selectedDef);
publicAPI.render(selectedDef.name);
})
.on('change', () => {
// committed to a value, show feedback.
let val = d3.event.target.value;
if (!validateDividerVal(val)) val = savedVal;
else {
val = Math.min(hobj.max, Math.max(hobj.min, val));
d3.event.target.value = val;
savedVal = val;
}
moveDragDivider(val, selectedDef);
publicAPI.render(selectedDef.name);
})
.on('keyup', () => {
// revert to last committed value
if (d3.event.key === 'Escape') {
moveDragDivider(savedVal, selectedDef);
dPopupDiv.on('mouseleave')();
} else if (d3.event.key === 'Enter' || d3.event.key === 'Return') {
// commit current value
dPopupDiv.on('mouseleave')();
}
});
// initial select/focus so use can immediately change the value.
valInput.node().select();
valInput.node().focus();
}

// Divider editing popup allows changing its value or uncertainty, or deleting it.
function createDividerPopup() {
const dPopupDiv = d3
.select(model.listContainer)
.append('div')
.classed(style.dividerPopup, true)
.style('display', 'none');
const table = dPopupDiv.append('table');
const tr1 = table.append('tr');
tr1
.append('td')
.classed(style.popupCell, true)
.text('Value:');
tr1
.append('td')
.classed(style.popupCell, true)
.append('input')
.classed(style.jsDividerValueInput, true)
.attr('type', 'number')
.attr('step', 'any')
.style('width', '6em');
const tr2 = table.append('tr');
tr2
.append('td')
.classed(style.popupCell, true)
.text('Uncertainty:');
tr2
.append('td')
.classed(style.popupCell, true)
.append('input')
.classed(style.jsDividerUncertaintyInput, true)
.attr('type', 'number')
.attr('step', 'any')
.style('width', '6em');
if (!model.showUncertainty) {
// if we aren't supposed to show uncertainty, hide this row.
tr2.style('display', 'none');
}
dPopupDiv.append('div').classed(style.scoreDashSpacer, true);
dPopupDiv
.append('div')
.style('text-align', 'center')
.append('input')
.classed(style.scoreButton, true)
.style('align', 'center')
.attr('type', 'button')
.attr('value', 'Delete Divider')
.on('click', () => {
finishDivider(model.selectedDef, model.selectedDef.hobj, true);
dPopupDiv.style('display', 'none');
publicAPI.render();
});
return dPopupDiv;
}
// Divider editing popup allows changing its value, only.
function createDividerValuePopup() {
const dPopupDiv = d3
.select(model.listContainer)
.append('div')
.classed(style.dividerValuePopup, true)
.style('display', 'none');
const table = dPopupDiv.append('table');
const tr1 = table.append('tr');
tr1
.append('td')
.classed(style.popupCell, true)
.text('Value:');
tr1
.append('td')
.classed(style.popupCell, true)
.append('input')
.classed(style.jsDividerValueInput, true)
.attr('type', 'number')
.attr('step', 'any')
.style('width', '6em');
return dPopupDiv;
}

function showScorePopup(sPopupDiv, coord, selRow) {
// it seemed like a good idea to use getBoundingClientRect() to determine row height
// but it returns all zeros when the popup has been invisible...
const topMargin = 4;
const rowHeight = 26;

sPopupDiv.style('display', null);
positionPopup(
sPopupDiv,
coord[0] - topMargin - 0.6 * rowHeight,
coord[1] + model.headerSize - (topMargin + (0.6 + selRow) * rowHeight)
);

sPopupDiv
.selectAll(`.${style.jsScoreLabel}`)
.style('background-color', (d, i) => (i === selRow ? d.bgColor : '#fff'));
}

function createScorePopup() {
const sPopupDiv = d3
.select(model.listContainer)
.append('div')
.classed(style.scorePopup, true)
.style('display', 'none')
.on('mouseleave', () => {
sPopupDiv.style('display', 'none');
model.selectedDef.dragDivider = undefined;
});
// create radio-buttons that allow choosing the score for the selected region
const scoreChoices = sPopupDiv
.selectAll(`.${style.jsScoreChoice}`)
.data(model.scores);
scoreChoices
.enter()
.append('label')
.classed(style.scoreLabel, true)
.text((d) => d.name)
.each(function myLabel(data, index) {
// because we use 'each' and re-select the label, need to use parent 'index'
// instead of 'i' in the (d, i) => functions below - i is always zero.
const label = d3.select(this);
label
.append('span')
.classed(style.scoreSwatch, true)
.style('background-color', (d) => d.color);
label
.append('input')
.classed(style.scoreChoice, true)
.attr('name', 'score_choice_rb')
.attr('type', 'radio')
.attr('value', (d) => d.name)
.property('checked', (d) => index === model.defaultScore)
.on('click', (d) => {
// use click, not change, so we get notified even when current value is chosen.
const def = model.selectedDef;
def.regions[def.hitRegionIndex] = index;
def.dragDivider = undefined;
sPopupDiv.style('display', 'none');
sendScores(def);
publicAPI.render();
});
});
sPopupDiv.append('div').classed(style.scoreDashSpacer, true);
// create a button for creating a new divider, so we don't require
// the invisible alt/ctrl click to create one.
sPopupDiv
.append('input')
.classed(style.scoreButton, true)
.attr('type', 'button')
.attr('value', 'New Divider')
.on('click', () => {
finishDivider(model.selectedDef, model.selectedDef.hobj);
sPopupDiv.style('display', 'none');
publicAPI.render();
});
return sPopupDiv;
}

function createPopups() {
if (enabled()) {
scorePopupDiv = d3
.select(model.listContainer)
.select(`.${style.jsScorePopup}`);
if (scorePopupDiv.empty()) {
scorePopupDiv = createScorePopup();
}
dividerPopupDiv = d3
.select(model.listContainer)
.select(`.${style.jsDividerPopup}`);
if (dividerPopupDiv.empty()) {
dividerPopupDiv = createDividerPopup();
}
dividerValuePopupDiv = d3
.select(model.listContainer)
.select(`.${style.jsDividerValuePopup}`);
if (dividerValuePopupDiv.empty()) {
dividerValuePopupDiv = createDividerValuePopup();
}
}
}

// when the Histogram1DProvider pushes a new histogram, it may have a new range.
// If needed, proportionally scale dividers into the new range.
function rescaleDividers(paramName, oldRangeMin, oldRangeMax) {
if (model.fieldData[paramName] && model.fieldData[paramName].hobj) {
const def = model.fieldData[paramName];
// Since we come in here whenever a new histogram gets pushed to the histo
// selector, avoid rescaling dividers unless there is actually a partition
// annotation on this field.
if (showScore(def)) {
const hobj = model.fieldData[paramName].hobj;
if (hobj.min !== oldRangeMin || hobj.max !== oldRangeMax) {
def.dividers.forEach((divider, index) => {
if (oldRangeMax === oldRangeMin) {
// space dividers evenly in the middle - i.e. punt.
divider.value =
((index + 1) / (def.dividers.length + 1)) *
(hobj.max - hobj.min) +
hobj.min;
} else {
// this set the divider to hobj.min if the new hobj.min === hobj.max.
divider.value =
((divider.value - oldRangeMin) / (oldRangeMax - oldRangeMin)) *
(hobj.max - hobj.min) +
hobj.min;
}
});
sendScores(def, true);
}
}
}
}

function editingScore(def) {
return def.editScore;
}

function filterFieldNames(fieldNames) {
if (getDisplayOnlyScored()) {
// filter for fields that have scores
return fieldNames.filter((name) => showScore(model.fieldData[name]));
}
return fieldNames;
}

function prepareItem(def, idx, svgGr, tdsl) {
if (!enabled()) return;
if (typeof def.dividers === 'undefined') {
def.dividers = [];
def.regions = [model.defaultScore];
def.editScore = false;
def.lockAnnot = false;
}
const hobj = def.hobj;

const gScore = svgGr.select(`.${style.jsScore}`);
let drag = null;
if (def.editScore) {
// add temp dragged divider, if needed.
const dividerData =
typeof def.dragDivider !== 'undefined' &&
def.dragDivider.newDivider.value !== undefined
? def.dividers.concat(def.dragDivider.newDivider)
: def.dividers;
const dividers = gScore.selectAll('line').data(dividerData);
dividers.enter().append('line');
dividers
.attr('x1', (d) => def.xScale(d.value))
.attr('y1', 0)
.attr('x2', (d) => def.xScale(d.value))
.attr('y2', () => model.histHeight)
.attr('stroke-width', 1)
.attr('stroke', 'black');
dividers.exit().remove();

const uncertScale = getUncertScale(def);
const uncertRegions = gScore
.selectAll(`.${style.jsScoreUncertainty}`)
.data(dividerData);
uncertRegions
.enter()
.append('rect')
.classed(style.jsScoreUncertainty, true)
.attr('rx', 8)
.attr('ry', 8);
uncertRegions
.attr('x', (d) => def.xScale(d.value - d.uncertainty * uncertScale))
.attr('y', 0)
// to get a width, need to start from 'zero' of this scale, which is hobj.min
.attr('width', (d, i) =>
def.xScale(hobj.min + 2 * d.uncertainty * uncertScale)
)
.attr('height', () => model.histHeight)
.attr('fill', '#000')
.attr('opacity', (d) => (d.uncertainty > 0 ? '0.2' : '0'));
uncertRegions.exit().remove();

let dragDivLabel = gScore.select(`.${style.jsScoreDivLabel}`);
if (typeof def.dragDivider !== 'undefined') {
if (dragDivLabel.empty()) {
dragDivLabel = gScore
.append('text')
.classed(style.jsScoreDivLabel, true)
.attr('text-anchor', 'middle')
.attr('stroke', 'none')
.attr('background-color', '#fff')
.attr('dy', '.71em');
}
const formatter = d3.format('.3s');
const divVal =
def.dragDivider.newDivider.value !== undefined
? def.dragDivider.newDivider.value
: def.dividers[def.dragDivider.index].value;
dragDivLabel
.text(formatter(divVal))
.attr('x', `${def.xScale(divVal)}`)
.attr('y', `${model.histHeight + 2}`);
} else if (!dragDivLabel.empty()) {
dragDivLabel.remove();
}

// divider interaction events.
// Drag flow: drag a divider inside its current neighbors.
// A divider outside its neighbors or a new divider is a temp divider,
// added to the end of the list when rendering. Doesn't affect regions that way.
drag = d3.behavior
.drag()
.on('dragstart', () => {
const overCoords = publicAPI.getMouseCoords(tdsl);
const [val, , hitIndex] = dividerPick(
overCoords,
def,
model.dragMargin,
hobj.min
);
if (
!def.lockAnnot &&
(d3.event.sourceEvent.altKey || d3.event.sourceEvent.ctrlKey)
) {
// create a temp divider to render.
def.dragDivider = createDragDivider(-1, val, def, hobj);
publicAPI.render();
} else if (hitIndex >= 0) {
// start dragging existing divider
// it becomes a temporary copy if we go outside our bounds
def.dragDivider = createDragDivider(hitIndex, undefined, def, hobj);
publicAPI.render();
}
})
.on('drag', () => {
const overCoords = publicAPI.getMouseCoords(tdsl);
if (
typeof def.dragDivider === 'undefined' ||
scorePopupDiv.style('display') !== 'none' ||
dividerPopupDiv.style('display') !== 'none' ||
dividerValuePopupDiv.style('display') !== 'none'
)
return;
const val = def.xScale.invert(overCoords[0]);
moveDragDivider(val, def);
publicAPI.render(def.name);
})
.on('dragend', () => {
if (
typeof def.dragDivider === 'undefined' ||
scorePopupDiv.style('display') !== 'none' ||
dividerPopupDiv.style('display') !== 'none' ||
dividerValuePopupDiv.style('display') !== 'none'
)
return;
finishDivider(def, hobj);
publicAPI.render();
});
} else {
gScore.selectAll('line').remove();
gScore.selectAll(`.${style.jsScoreUncertainty}`).remove();
}

// score regions
// there are implicit bounds at the min and max.
const regionBounds = getRegionBounds(def);
const scoreRegions = gScore
.selectAll(`.${style.jsScoreRect}`)
.data(def.regions);
// duplicate background regions are opaque, for a solid bright color.
const scoreBgRegions = svgGr
.select(`.${style.jsScoreBackground}`)
.selectAll('rect')
.data(def.regions);
const numRegions = def.regions.length;
[
{ sel: scoreRegions, opacity: 0.2, class: style.scoreRegionFg },
{ sel: scoreBgRegions, opacity: 1.0, class: style.scoreRegionBg },
].forEach((reg) => {
reg.sel
.enter()
.append('rect')
.classed(reg.class, true);
// first and last region should hang 6 pixels over the start/end of the axis.
const overhang = 6;
reg.sel
.attr(
'x',
(d, i) => def.xScale(regionBounds[i]) - (i === 0 ? overhang : 0)
)
.attr('y', def.editScore ? 0 : model.histHeight)
// width might be === overhang if a divider is dragged all the way to min/max.
.attr(
'width',
(d, i) =>
def.xScale(regionBounds[i + 1]) -
def.xScale(regionBounds[i]) +
(i === 0 ? overhang : 0) +
(i === numRegions - 1 ? overhang : 0)
)
// extend over the x-axis when editing.
.attr(
'height',
def.editScore
? model.histHeight + model.histMargin.bottom - 3
: model.histMargin.bottom - 3
)
.attr('fill', (d) => model.scores[d].color)
.attr('opacity', showScore(def) ? reg.opacity : '0');
reg.sel.exit().remove();
});

// invisible overlay to catch mouse events. Sized correctly in HistogramSelector
const svgOverlay = svgGr.select(`.${style.jsOverlay}`);
svgOverlay
.on('click.score', () => {
// preventDefault() in dragstart didn't help, so watch for altKey or ctrlKey.
if (d3.event.defaultPrevented || d3.event.altKey || d3.event.ctrlKey)
return; // click suppressed (by drag handling)
const overCoords = publicAPI.getMouseCoords(tdsl);
if (overCoords[1] > model.histHeight) {
// def.editScore = !def.editScore;
// svgOverlay.style('cursor', def.editScore ? `url(${downArrowImage}) 12 22, auto` : 'pointer');
// if (def.editScore && model.provider.isA('HistogramBinHoverProvider')) {
// const state = {};
// state[def.name] = [-1];
// model.provider.setHoverState({ state });
// }
// // set existing annotation as current if we activate for editing
// if (def.editScore && showScore(def) && def.annotation) {
// // TODO special 'active' method to call, instead of an edit?
// sendScores(def);
// }
// publicAPI.render(def.name);
return;
}
if (def.editScore) {
// if we didn't create or drag a divider, pick a region or divider
const hitRegionIndex = regionPick(overCoords, def, hobj);
// select a def, show popup.
def.hitRegionIndex = hitRegionIndex;
// create a temp divider in case we choose 'new |' from the popup.
const [val, , hitIndex] = dividerPick(
overCoords,
def,
model.dragMargin,
hobj.min
);
const coord = d3.mouse(model.listContainer);
model.selectedDef = def;
if (hitIndex >= 0) {
// pick an existing divider, popup to edit value, uncertainty, or delete.
def.dragDivider = createDragDivider(hitIndex, undefined, def, hobj);
if (!def.lockAnnot)
showDividerPopup(dividerPopupDiv, model.selectedDef, hobj, coord);
else
showDividerValuePopup(
dividerValuePopupDiv,
model.selectedDef,
hobj,
coord
);
} else if (!def.lockAnnot) {
if (typeof def.dragDivider === 'undefined') {
def.dragDivider = createDragDivider(-1, val, def, hobj);
} else {
console.log('Internal: unexpected existing divider');
def.dragDivider.newDivider.value = val;
}

const selRow = def.regions[def.hitRegionIndex];
showScorePopup(scorePopupDiv, coord, selRow);
}
}
})
.on('mousemove.score', () => {
const overCoords = publicAPI.getMouseCoords(tdsl);
if (def.editScore) {
const [, , hitIndex] = dividerPick(
overCoords,
def,
model.dragMargin,
hobj.min
);
let cursor = 'pointer';
// if we're over the bottom, indicate a click will shrink regions
if (overCoords[1] > model.histHeight) {
// cursor = `url(${downArrowImage}) 12 22, auto`;
// if we're over a divider, indicate drag-to-move
} else if (def.dragIndex >= 0 || hitIndex >= 0) cursor = 'ew-resize';
else if (d3.event.altKey || d3.event.ctrlKey)
// if modifiers are held down, we'll create a divider
cursor = 'crosshair';
svgOverlay.style('cursor', cursor);
} else {
// over the bottom, indicate we can start editing regions
const pickIt = overCoords[1] > model.histHeight;
svgOverlay.style('cursor', pickIt ? 'pointer' : 'default');
}
});
if (def.editScore) {
svgOverlay.call(drag);
} else {
svgOverlay.on('.drag', null);
}
}

function addSubscriptions() {
if (model.provider.isA('SelectionProvider')) {
model.subscriptions.push(
model.provider.onAnnotationChange((annotation) => {
if (annotation.selection.type === 'partition') {
const field = annotation.selection.partition.variable;
// ignore annotation if it's read-only and we aren't
if (
annotation.readOnly &&
model.readOnlyFields.indexOf(field) === -1
)
return;
// Vice-versa: single mode, displaying read-only, ignore external annots.
if (!annotation.readOnly && model.fieldData[field].lockAnnot)
return;

// respond to annotation.
model.fieldData[field].annotation = annotation;
partitionToDividers(
annotation,
model.fieldData[field],
model.scores
);

publicAPI.render(field);
}
})
);
}
}

// Works if model.fieldData[field].hobj is undefined.
function updateFieldAnnotations(fieldsData) {
let fieldAnnotations = fieldsData;
if (!fieldAnnotations && model.provider.getFieldPartitions) {
fieldAnnotations = model.provider.getFieldPartitions();
}
if (fieldAnnotations) {
Object.keys(fieldAnnotations).forEach((field) => {
const annotation = fieldAnnotations[field];
if (model.fieldData[field]) {
model.fieldData[field].annotation = annotation;
partitionToDividers(annotation, model.fieldData[field], model.scores);
publicAPI.render(field);
}
});
}
}

function clearFieldAnnotation(fieldName) {
model.fieldData[fieldName].annotation = null;
model.fieldData[fieldName].dividers = undefined;
model.fieldData[fieldName].regions = [model.defaultScore];
model.fieldData[fieldName].editScore = false;
}

return {
addSubscriptions,
createGroups,
createHeader,
createPopups,
createScoreIcons,
defaultFieldData,
editingScore,
enabled,
filterFieldNames,
getHistRange,
init,
numScoreIcons,
prepareItem,
rescaleDividers,
updateHeader,
updateFieldAnnotations,
clearFieldAnnotation,
updateScoreIcons,
};
}