piling.js
Version:
A WebGL-based Library for Visual Piling/Stacking
1,859 lines (1,535 loc) • 149 kB
JavaScript
import * as PIXI from 'pixi.js';
import createDom2dCamera from 'dom-2d-camera';
import { mat4, vec4 } from 'gl-matrix';
import createPubSub from 'pub-sub-es';
import withRaf from 'with-raf';
import RBush from 'rbush';
import normalizeWheel from 'normalize-wheel';
import { batchActions } from 'redux-batched-actions';
import {
addClass,
capitalize,
cubicOut,
debounce,
identity,
interpolateVector,
isFunction,
isObject,
isPointInPolygon,
l2PointDist,
lRectDist,
max,
maxVector,
mean,
meanVector,
median,
medianVector,
min,
minVector,
nextAnimationFrame,
removeClass,
sortAsc,
sortDesc,
sortPos,
sum,
sumVector,
unique,
} from '@flekschas/utils';
import convolve from 'ndarray-convolve';
import ndarray from 'ndarray';
import createAnimator from './animator';
import createBadgeFactory from './badge-factory';
import createLevels from './levels';
import createKmeans from './kmeans';
import createStore, { createAction } from './store';
import {
BLACK,
CAMERA_VIEW,
DEFAULT_COLOR_MAP,
EPS,
EVENT_LISTENER_ACTIVE,
EVENT_LISTENER_PASSIVE,
INHERIT,
INITIAL_ARRANGEMENT_TYPE,
INITIAL_ARRANGEMENT_OBJECTIVE,
NAVIGATION_MODE_AUTO,
NAVIGATION_MODE_PAN_ZOOM,
NAVIGATION_MODE_SCROLL,
POSITION_PILES_DEBOUNCE_TIME,
UNKNOWN_LABEL,
WHITE,
} from './defaults';
import {
cloneSprite,
colorToDecAlpha,
createScale,
deserializeState,
getBBox,
getItemProp,
getPileProp,
matchArrayPair,
serializeState,
toAlignment,
toHomogeneous,
uniqueStr,
zoomToScale,
} from './utils';
import createImage from './image';
import createImageWithBackground from './image-with-background';
import createPile from './pile';
import createGrid from './grid';
import createItem from './item';
import createTweener from './tweener';
import createContextMenu from './context-menu';
import createPopup from './popup';
import createLasso from './lasso';
import { version } from '../package.json';
// We cannot import the following libraries using the normal `import` statement
// as this blows up the Rollup bundle massively for some reasons...
// const convolve = require('ndarray-convolve');
// const ndarray = require('ndarray');
const EXTRA_ROWS = 3;
const l2RectDist = lRectDist(2);
const createPilingJs = (rootElement, initProps = {}) => {
const scrollContainer = document.createElement('div');
scrollContainer.className = 'pilingjs-scroll-container';
const scrollEl = document.createElement('div');
scrollEl.className = 'pilingjs-scroll-element';
const canvas = document.createElement('canvas');
canvas.className = 'pilingjs-canvas';
const pubSub = createPubSub();
const store = createStore();
const badgeFactory = createBadgeFactory();
let state = store.state;
let backgroundColor = WHITE;
let gridMat;
let transformPointToScreen;
let transformPointFromScreen;
let translatePointFromScreen;
let camera;
let attemptArrangement = false;
const scratch = new Float32Array(16);
const lastPilePosition = new Map();
let {
width: containerWidth,
height: containerHeight,
} = rootElement.getBoundingClientRect();
let destroyed = false;
let renderer = new PIXI.Renderer({
width: containerWidth,
height: containerHeight,
view: canvas,
antialias: true,
transparent: true,
resolution: window.devicePixelRatio,
autoDensity: true,
preserveDrawingBuffer: false,
legacy: false,
powerPreference: 'high-performance',
});
let isInitialPositioning = true;
let isPanZoom = null;
let isPanZoomed = false;
let arranging = Promise.resolve();
let root = new PIXI.Container();
const stage = new PIXI.Container();
root.addChild(stage);
const gridGfx = new PIXI.Graphics();
const spatialIndexGfx = new PIXI.Graphics();
const mask = new PIXI.Sprite(PIXI.Texture.WHITE);
root.addChild(mask);
stage.mask = mask;
const createColorOpacityActions = (colorAction, opacityAction) => (value) => {
if (isFunction(value)) return [createAction[colorAction](value)];
const [color, opacity] = colorToDecAlpha(value, null);
const actions = [createAction[colorAction](color)];
if (opacity !== null) actions.push(createAction[opacityAction](opacity));
return actions;
};
const properties = {
center: {
get: () => camera.target,
set: (point) => {
camera.lookAt(point, camera.scaling, camera.rotation);
},
noAction: true,
},
gridColor: {
set: createColorOpacityActions('setGridColor', 'setGridOpacity'),
},
items: {
get: () => Object.values(state.items),
set: (newItems) => [
createAction.setItems(newItems),
createAction.initPiles(newItems),
],
batchActions: true,
},
lassoFillColor: {
set: createColorOpacityActions(
'setLassoFillColor',
'setLassoFillOpacity'
),
},
lassoStrokeColor: {
set: createColorOpacityActions(
'setLassoStrokeColor',
'setLassoStrokeOpacity'
),
},
layout: {
get: () => ({
cellAspectRatio: layout.cellAspectRatio,
cellHeight: layout.cellHeight,
cellPadding: layout.cellPadding,
cellSize: layout.cellSize,
cellWidth: layout.cellWidth,
columnWidth: layout.columnWidth,
height: layout.height,
itemSize: layout.itemSize,
numColumns: layout.numColumns,
numRows: layout.numRows,
rowHeight: layout.rowHeight,
width: layout.width,
}),
},
pileBorderColor: {
set: createColorOpacityActions(
'setPileBorderColor',
'setPileBorderOpacity'
),
},
pileBorderColorHover: {
set: createColorOpacityActions(
'setPileBorderColorHover',
'setPileBorderOpacityHover'
),
},
pileBorderColorFocus: {
set: createColorOpacityActions(
'setPileBorderColorFocus',
'setPileBorderOpacityFocus'
),
},
pileBorderColorActive: {
set: createColorOpacityActions(
'setPileBorderColorActive',
'setPileBorderOpacityActive'
),
},
pileBackgroundColor: {
set: createColorOpacityActions(
'setPileBackgroundColor',
'setPileBackgroundOpacity'
),
},
pileBackgroundColorHover: {
set: createColorOpacityActions(
'setPileBackgroundColorHover',
'setPileBackgroundOpacityHover'
),
},
pileBackgroundColorFocus: {
set: createColorOpacityActions(
'setPileBackgroundColorFocus',
'setPileBackgroundOpacityFocus'
),
},
pileBackgroundColorActive: {
set: createColorOpacityActions(
'setPileBackgroundColorActive',
'setPileBackgroundOpacityActive'
),
},
pileLabel: {
set: (value) => {
const objective = expandLabelObjective(value);
const actions = [createAction.setPileLabel(objective)];
return actions;
},
},
pileLabelSizeTransform: {
set: (value) => {
const aggregator = expandLabelSizeAggregator(value);
const actions = [createAction.setPileLabelSizeTransform(aggregator)];
return actions;
},
},
pileLabelTextColor: {
set: createColorOpacityActions(
'setPileLabelTextColor',
'setPileLabelTextOpacity'
),
},
pileSizeBadgeAlign: {
set: (alignment) => [
createAction.setPileSizeBadgeAlign(
isFunction(alignment) ? alignment : toAlignment(alignment)
),
],
},
previewBackgroundColor: {
set: createColorOpacityActions(
'setPreviewBackgroundColor',
'setPreviewBackgroundOpacity'
),
},
previewBorderColor: {
set: createColorOpacityActions(
'setPreviewBorderColor',
'setPreviewBorderOpacity'
),
},
renderer: {
get: () => state.itemRenderer,
set: (value) => [createAction.setItemRenderer(value)],
},
rowHeight: true,
scale: {
get: () => camera.scaling,
set: (scale) => {
camera.scale(scale, [renderer.width / 2, renderer.height / 2]);
},
noAction: true,
},
};
const get = (property) => {
if (properties[property] && properties[property].get)
return properties[property].get();
if (state[property] !== undefined) return state[property];
console.warn(`Unknown property "${property}"`);
return undefined;
};
const set = (property, value, noDispatch = false) => {
const config = properties[property];
let actions = [];
if (config) {
if (config.set) {
actions = config.set(value);
} else {
console.warn(`Property "${property}" is not settable`);
}
} else if (state[property] !== undefined) {
actions = [createAction[`set${capitalize(property)}`](value)];
} else {
console.warn(`Unknown property "${property}"`);
}
if (noDispatch || (config && config.noAction)) return actions;
if (config && config.batchActions) {
store.dispatch(batchActions(actions));
} else {
actions.forEach((action) => store.dispatch(action));
}
return undefined;
};
const setPromise = (propVals) => {
if (propVals.items)
return new Promise((resolve) => {
pubSub.subscribe('itemUpdate', resolve, 1);
});
return Promise.resolve();
};
const setPublic = (newProperty, newValue) => {
if (typeof newProperty === 'string' || newProperty instanceof String) {
const whenDone = setPromise({ [newProperty]: newValue });
set(newProperty, newValue);
return whenDone;
}
const whenDone = setPromise(newProperty);
store.dispatch(
batchActions(
Object.entries(newProperty).flatMap(([property, value]) =>
set(property, value, true)
)
)
);
return whenDone;
};
const render = () => {
renderer.render(root);
pubSub.publish('render');
};
const renderRaf = withRaf(render);
const animator = createAnimator(render, pubSub);
const renderedItems = new Map();
const pileInstances = new Map();
const activePile = new PIXI.Container();
const normalPiles = new PIXI.Container();
const filterLayer = new PIXI.Sprite(PIXI.Texture.WHITE);
filterLayer.alpha = 0;
const clearActivePileLayer = () => {
if (activePile.children.length) {
normalPiles.addChild(activePile.getChildAt(0));
activePile.removeChildren();
}
};
const moveToActivePileLayer = (pileGfx) => {
clearActivePileLayer();
activePile.addChild(pileGfx);
};
const popup = createPopup();
let isInteractive = false;
const disableInteractivity = () => {
if (!isInteractive) return;
stage.interactive = false;
stage.interactiveChildren = false;
pileInstances.forEach((pile) => {
pile.disableInteractivity();
});
isInteractive = false;
};
const enableInteractivity = () => {
if (isInteractive) return;
stage.interactive = true;
stage.interactiveChildren = true;
pileInstances.forEach((pile) => {
pile.enableInteractivity();
});
isInteractive = true;
};
let isMouseDown = false;
let isLasso = false;
const lasso = createLasso({
onStart: () => {
disableInteractivity();
isLasso = true;
isMouseDown = true;
},
onDraw: () => {
renderRaf();
},
});
stage.addChild(gridGfx);
stage.addChild(spatialIndexGfx);
stage.addChild(lasso.fillContainer);
stage.addChild(normalPiles);
stage.addChild(filterLayer);
stage.addChild(activePile);
stage.addChild(lasso.lineContainer);
const spatialIndex = new RBush();
const drawSpatialIndex = (mousePos, lassoPolygon) => {
if (!store.state.showSpatialIndex) return;
spatialIndexGfx.clear();
spatialIndexGfx.beginFill(0x00ff00, 0.5);
spatialIndex.all().forEach((bBox) => {
spatialIndexGfx.drawRect(bBox.minX, bBox.minY, bBox.width, bBox.height);
});
spatialIndexGfx.endFill();
if (mousePos) {
spatialIndexGfx.beginFill(0xff0000, 1.0);
spatialIndexGfx.drawRect(mousePos[0] - 1, mousePos[1] - 1, 3, 3);
spatialIndexGfx.endFill();
}
if (lassoPolygon) {
spatialIndexGfx.lineStyle(1, 0xff0000, 1.0);
spatialIndexGfx.moveTo(lassoPolygon[0], lassoPolygon[1]);
for (let i = 0; i < lassoPolygon.length; i += 2) {
spatialIndexGfx.lineTo(lassoPolygon[i], lassoPolygon[i + 1]);
spatialIndexGfx.moveTo(lassoPolygon[i], lassoPolygon[i + 1]);
}
}
};
const createRBush = () => {
spatialIndex.clear();
const boxList = [];
if (pileInstances) {
pileInstances.forEach((pile) => {
pile.updateBounds(...getXyOffset(), true);
boxList.push(pile.bBox);
});
spatialIndex.load(boxList);
drawSpatialIndex();
}
};
const deletePileFromSearchIndex = (pileId) => {
const pile = pileInstances.get(pileId);
spatialIndex.remove(pile.bBox, (a, b) => {
return a.id === b.id;
});
drawSpatialIndex();
};
const getXyOffset = () => {
if (isPanZoom) {
return camera.translation;
}
return [0, stage.y];
};
const calcPileBBox = (pileId) => {
return pileInstances.get(pileId).calcBBox(...getXyOffset());
};
const updatePileBounds = (pileId, { forceUpdate = false } = {}) => {
const pile = pileInstances.get(pileId);
spatialIndex.remove(pile.bBox, (a, b) => a.id === b.id);
pile.updateBounds(...getXyOffset(), forceUpdate);
spatialIndex.insert(pile.bBox);
drawSpatialIndex();
pubSub.publish('pileBoundsUpdate', pileId);
};
const updatePileBoundsHandler = ({ id, forceUpdate }) =>
updatePileBounds(id, { forceUpdate });
const transformPiles = () => {
lastPilePosition.forEach((pilePos, pileId) => {
movePileTo(pileInstances.get(pileId), pilePos[0], pilePos[1]);
});
renderRaf();
};
const zoomPiles = () => {
const { zoomScale } = store.state;
const scaling = isFunction(zoomScale)
? zoomScale(camera.scaling)
: zoomScale;
pileInstances.forEach((pile) => {
pile.setZoomScale(scaling);
pile.drawBorder();
});
renderRaf();
};
const panZoomHandler = (updatePilePosition = true) => {
// Update the camera
camera.tick();
transformPiles();
zoomPiles();
isPanZoomed = true;
if (updatePilePosition) positionPilesDb();
pubSub.publish('zoom', camera);
};
let prevScaling = 1;
const zoomHandler = () => {
if (!camera) return;
const {
groupingType,
groupingObjective,
groupingOptions,
splittingType,
splittingObjective,
splittingOptions,
} = store.state;
const currentScaling = camera.scaling;
const dScaling = currentScaling / prevScaling;
if (groupingType && dScaling < 1) {
groupBy(groupingType, groupingObjective, groupingOptions);
}
if (splittingType && dScaling > 1) {
splitBy(splittingType, splittingObjective, splittingOptions);
}
prevScaling = currentScaling;
};
const zoomHandlerDb = debounce(zoomHandler, 250);
const panZoomEndHandler = () => {
if (!isPanZoomed) return;
isPanZoomed = false;
// Update the camera
camera.tick();
transformPiles();
pubSub.publish('zoom', camera);
};
let layout;
const updateScrollHeight = () => {
const canvasHeight = canvas.getBoundingClientRect().height;
const finalHeight =
Math.round(layout.rowHeight) * (layout.numRows + EXTRA_ROWS);
scrollEl.style.height = `${Math.max(0, finalHeight - canvasHeight)}px`;
if (store.state.showGrid) {
drawGrid();
}
};
const enableScrolling = () => {
if (isPanZoom === false) return false;
disablePanZoom();
isPanZoom = false;
transformPointToScreen = identity;
transformPointFromScreen = identity;
translatePointFromScreen = identity;
stage.y = 0;
scrollContainer.style.overflowY = 'auto';
scrollContainer.scrollTop = 0;
scrollContainer.addEventListener(
'scroll',
mouseScrollHandler,
EVENT_LISTENER_PASSIVE
);
window.addEventListener(
'mousedown',
mouseDownHandler,
EVENT_LISTENER_PASSIVE
);
window.addEventListener('mouseup', mouseUpHandler, EVENT_LISTENER_PASSIVE);
window.addEventListener(
'mousemove',
mouseMoveHandler,
EVENT_LISTENER_PASSIVE
);
canvas.addEventListener('wheel', wheelHandler, EVENT_LISTENER_ACTIVE);
return true;
};
const disableScrolling = () => {
if (isPanZoom !== false) return;
stage.y = 0;
scrollContainer.style.overflowY = 'hidden';
scrollContainer.scrollTop = 0;
scrollContainer.removeEventListener('scroll', mouseScrollHandler);
window.removeEventListener('mousedown', mouseDownHandler);
window.removeEventListener('mouseup', mouseUpHandler);
window.removeEventListener('mousemove', mouseMoveHandler);
canvas.removeEventListener('wheel', wheelHandler);
};
const enablePanZoom = () => {
if (isPanZoom === true) return false;
disableScrolling();
isPanZoom = true;
transformPointToScreen = transformPointToCamera;
transformPointFromScreen = transformPointFromCamera;
translatePointFromScreen = translatePointFromCamera;
camera = createDom2dCamera(canvas, {
isNdc: false,
onMouseDown: mouseDownHandler,
onMouseUp: mouseUpHandler,
onMouseMove: mouseMoveHandler,
onWheel: wheelHandler,
viewCenter: [containerWidth / 2, containerHeight / 2],
scaleBounds: store.state.zoomBounds.map(zoomToScale),
});
camera.setView(mat4.clone(CAMERA_VIEW));
return true;
};
const disablePanZoom = () => {
if (isPanZoom !== true) return;
camera.dispose();
camera = undefined;
};
const drawGrid = () => {
const height =
scrollEl.getBoundingClientRect().height +
canvas.getBoundingClientRect().height;
const { width } = canvas.getBoundingClientRect();
const vLineNum = Math.ceil(width / layout.columnWidth);
const hLineNum = Math.ceil(height / layout.rowHeight);
const { gridColor, gridOpacity } = store.state;
gridGfx.clear();
gridGfx.lineStyle(1, gridColor, gridOpacity);
// vertical lines
for (let i = 1; i < vLineNum; i++) {
gridGfx.moveTo(i * layout.columnWidth, 0);
gridGfx.lineTo(i * layout.columnWidth, height);
}
// horizontal lines
for (let i = 1; i < hLineNum; i++) {
gridGfx.moveTo(0, i * layout.rowHeight);
gridGfx.lineTo(width, i * layout.rowHeight);
}
};
const clearGrid = () => {
gridGfx.clear();
};
const initGrid = () => {
const { orderer } = store.state;
layout = createGrid(
{ width: containerWidth, height: containerHeight, orderer },
store.state
);
updateScrollHeight();
};
const updateGrid = () => {
const { orderer } = store.state;
const oldLayout = layout;
layout = createGrid(
{ width: containerWidth, height: containerHeight, orderer },
store.state
);
// eslint-disable-next-line no-use-before-define
updateLayout(oldLayout, layout);
updateScrollHeight();
if (store.state.showGrid) {
drawGrid();
}
};
const levelLeaveHandler = ({ width, height }) => {
pubSub.subscribe(
'animationEnd',
() => {
if (layout.width !== width || layout.height !== height) {
// Set layout to the old layout given the old element width and height
layout = createGrid(
{ width, height, orderer: store.state.orderer },
store.state
);
updateGrid();
}
},
1
);
};
const levels = createLevels(
{ element: canvas, pubSub, store },
{ onLeave: levelLeaveHandler }
);
const halt = async (options) => {
await popup.open(options);
if (isPanZoom) camera.config({ isFixed: true });
else scrollContainer.style.overflowY = 'hidden';
};
const resume = () => {
popup.close();
if (isPanZoom) camera.config({ isFixed: false });
else scrollContainer.style.overflowY = 'auto';
};
const updateHalt = () => {
const { darkMode, haltBackgroundOpacity } = store.state;
popup.set({
backgroundOpacity: haltBackgroundOpacity,
darkMode,
});
};
const updateLevels = () => {
const { darkMode } = store.state;
levels.set({
darkMode,
});
};
const updateLasso = () => {
const {
darkMode,
lassoFillColor,
lassoFillOpacity,
lassoShowStartIndicator,
lassoStartIndicatorOpacity,
lassoStrokeColor,
lassoStrokeOpacity,
lassoStrokeSize,
} = store.state;
lasso.set({
fillColor: lassoFillColor,
fillOpacity: lassoFillOpacity,
showStartIndicator: lassoShowStartIndicator,
startIndicatorOpacity: lassoStartIndicatorOpacity,
strokeColor: lassoStrokeColor,
strokeOpacity: lassoStrokeOpacity,
strokeSize: lassoStrokeSize,
darkMode,
});
};
let itemWidthScale = createScale();
let itemHeightScale = createScale();
const getImageScaleFactor = (image) =>
image.aspectRatio > layout.cellAspectRatio
? itemWidthScale(image.originalWidth) / image.originalWidth
: itemHeightScale(image.originalHeight) / image.originalHeight;
const scaleItems = () => {
if (!renderedItems.size) return;
let minWidth = Infinity;
let maxWidth = 0;
let minHeight = Infinity;
let maxHeight = 0;
let minAspectRatio = Infinity;
let maxAspectRatio = 0;
renderedItems.forEach((item) => {
const width = item.image.originalWidth;
const height = item.image.originalHeight;
if (width > maxWidth) maxWidth = width;
if (width < minWidth) minWidth = width;
if (height > maxHeight) maxHeight = height;
if (height < minHeight) minHeight = height;
const aspectRatio = width / height;
if (aspectRatio > maxAspectRatio) maxAspectRatio = aspectRatio;
if (aspectRatio < minAspectRatio) minAspectRatio = aspectRatio;
});
const { itemSizeRange, itemSize, piles, previewScaling } = store.state;
const itemWidth = itemSize || layout.cellWidth;
const itemHeight = itemSize || layout.cellHeight;
let widthRange;
let heightRange;
// if it's within [0, 1] assume it's relative
if (
itemSizeRange[0] > 0 &&
itemSizeRange[0] <= 1 &&
itemSizeRange[1] > 0 &&
itemSizeRange[1] <= 1
) {
widthRange = [itemWidth * itemSizeRange[0], itemWidth * itemSizeRange[1]];
heightRange = [
itemHeight * itemSizeRange[0],
itemHeight * itemSizeRange[1],
];
} else {
widthRange = [0, itemWidth];
heightRange = [0, itemHeight];
}
itemWidthScale = createScale()
.domain([minWidth, maxWidth])
.range(widthRange);
itemHeightScale = createScale()
.domain([minHeight, maxHeight])
.range(heightRange);
Object.values(piles).forEach((pile) => {
const scaling = isFunction(previewScaling)
? previewScaling(pile)
: previewScaling;
pile.items.forEach((itemId) => {
const item = renderedItems.get(itemId);
if (!item) return;
const scaleFactor = getImageScaleFactor(item.image);
item.image.scale(scaleFactor);
if (item.preview) {
const xScale = 1 + (scaleFactor * scaling[0] - 1);
const yScale = 1 + (scaleFactor * scaling[1] - 1);
item.preview.scaleX(xScale);
item.preview.scaleY(yScale);
item.preview.rescaleBackground();
}
});
});
pileInstances.forEach((pile) => {
if (pile.cover) {
const scaleFactor = getImageScaleFactor(pile.cover);
pile.cover.scale(scaleFactor);
}
pile.updateOffset();
pile.drawBorder();
});
};
const movePileTo = (pile, x, y) =>
pile.moveTo(...transformPointToScreen([x, y]));
const movePileToWithUpdate = (pile, x, y) => {
if (movePileTo(pile, x, y)) updatePileBounds(pile.id);
};
const getPileMoveToTweener = (pile, x, y, options) =>
pile.getMoveToTweener(...transformPointToScreen([x, y]), options);
const animateMovePileTo = (pile, x, y, options) =>
pile.animateMoveTo(...transformPointToScreen([x, y]), options);
const updateLayout = (oldLayout) => {
const { arrangementType, items } = store.state;
scaleItems();
if (arrangementType === null && !isPanZoom) {
// Since there is no automatic arrangement in place we manually move
// piles from their old cell position to their new cell position
const movingPiles = [];
layout.numRows = Math.ceil(renderedItems.size / layout.numColumns);
pileInstances.forEach((pile) => {
const pos = oldLayout.getPilePosByCellAlignment(pile);
const [oldRowNum, oldColumnNum] = oldLayout.xyToIj(pos[0], pos[1]);
pile.updateOffset();
updatePileBounds(pile.id, { forceUpdate: true });
const oldCellIndex = oldLayout.ijToIdx(oldRowNum, oldColumnNum);
const [x, y] = layout.idxToXy(
oldCellIndex,
pile.anchorBox.width,
pile.anchorBox.height,
pile.offset
);
movingPiles.push({ id: pile.id, x, y });
});
pileInstances.forEach((pile) => {
if (pile.cover) {
positionItems(pile.id);
}
});
store.dispatch(createAction.movePiles(movingPiles));
renderedItems.forEach((item) => {
item.setOriginalPosition(
layout.idxToXy(
items[item.id].index,
item.image.width,
item.image.height,
item.image.center
)
);
});
} else {
positionPiles();
}
createRBush();
store.state.focusedPiles
.filter((pileId) => pileInstances.has(pileId))
.forEach((pileId) => {
pileInstances.get(pileId).focus();
});
updateScrollHeight();
renderRaf();
};
const getBackgroundColor = (pileState) => {
const bgColor = getPileProp(store.state.pileBackgroundColor, pileState);
if (bgColor !== null) return bgColor;
return backgroundColor;
};
const createImagesAndPreviews = (items) => {
const {
itemRenderer,
previewBackgroundColor,
previewBackgroundOpacity,
pileBackgroundOpacity,
previewAggregator,
previewRenderer,
previewPadding,
piles,
} = store.state;
const itemList = Object.values(items);
const renderImages = itemRenderer(
itemList.map(({ src }) => src)
).then((textures) => textures.map(createImage));
const createPreview = (texture, index) => {
if (texture === null) return null;
const itemState = itemList[index];
const pileState = piles[itemState.id];
const pileBackgroundColor = getBackgroundColor(pileState);
const previewOptions = {
backgroundColor:
previewBackgroundColor === INHERIT
? pileBackgroundColor
: getItemProp(previewBackgroundColor, itemState),
backgroundOpacity:
previewBackgroundOpacity === INHERIT
? getPileProp(pileBackgroundOpacity, pileState)
: getItemProp(previewBackgroundOpacity, itemState),
padding: getItemProp(previewPadding, itemState),
};
return createImageWithBackground(texture, previewOptions);
};
const asyncIdentity = async (x) => x.map((y) => y.src);
const aggregator = previewRenderer
? previewAggregator || asyncIdentity
: null;
const renderPreviews = aggregator
? Promise.resolve(aggregator(itemList))
.then(
(aggregatedItemSources) =>
new Promise((resolve) => {
// Manually handle `null` values
const response = [];
previewRenderer(
aggregatedItemSources.filter((src) => src !== null)
).then((textures) => {
let i = 0;
aggregatedItemSources.forEach((src) => {
if (src === null) {
response.push(null);
} else {
response.push(textures[i++]);
}
});
resolve(response);
});
})
)
.then((textures) => textures.map(createPreview))
: Promise.resolve([]);
return [renderImages, renderPreviews];
};
const updateItemTexture = async (updatedItems = null) => {
const { items, piles } = store.state;
if (!updatedItems) {
// eslint-disable-next-line no-param-reassign
updatedItems = items;
}
await halt();
return Promise.all(createImagesAndPreviews(updatedItems)).then(
([renderedImages, renderedPreviews]) => {
const updatedItemIds = Object.keys(updatedItems);
renderedImages.forEach((image, index) => {
const itemId = updatedItemIds[index];
const preview = renderedPreviews[itemId];
if (renderedItems.has(itemId)) {
renderedItems.get(itemId).replaceImage(image, preview);
}
});
updatedItemIds.forEach((itemId) => {
if (pileInstances.has(itemId)) {
const pile = pileInstances.get(itemId);
const pileState = piles[itemId];
pile.replaceItemsImage();
pile.blur();
updatePileStyle(pileState, itemId);
updatePileItemStyle(pileState, itemId);
clearActivePileLayer();
} else {
// Just update part of items on a pile
Object.values(piles).forEach((pileState) => {
if (pileState.items.includes(itemId)) {
const pile = pileInstances.get(pileState.id);
pile.replaceItemsImage(itemId);
pile.blur();
}
});
}
});
pileInstances.forEach((pile) => {
updatePreviewAndCover(piles[pile.id], pile);
});
scaleItems();
renderRaf();
resume();
}
);
};
const createItemsAndPiles = async (newItems) => {
const newItemIds = Object.keys(newItems);
if (!newItemIds.length) return Promise.resolve();
await halt();
return Promise.all(createImagesAndPreviews(newItems)).then(
([renderedImages, renderedPreviews]) => {
const { piles } = store.state;
renderedImages.forEach((image, index) => {
const preview = renderedPreviews[index];
const id = newItemIds[index];
const newItem = createItem({ id, image, pubSub }, { preview });
renderedItems.set(id, newItem);
});
// We cannot combine the two loops as we might have initialized
// piling.js with a predefined pile state. In that case a pile of
// with a lower index might rely on an item with a higher index that
// hasn't been created yet.
renderedImages.forEach((image, index) => {
const id = newItemIds[index];
const pileState = piles[id];
if (pileState.items.length) createPileHandler(id, pileState);
});
scaleItems();
renderRaf();
resume();
}
);
};
const isPileUnpositioned = (pile) => pile.x === null || pile.y === null;
const getPilePositionBy1dOrdering = (pileId) => {
const pile = pileInstances.get(pileId);
const pos = layout.idxToXy(
pileSortPosByAggregate[0][pileId],
pile.width,
pile.height,
pile.offset
);
return Promise.resolve(pos);
};
const getPilePositionBy2dScales = (pileId) =>
Promise.resolve(
arrangement2dScales.map((scale, i) =>
scale(aggregatedPileValues[pileId][i])
)
);
const cachedMdPilePos = new Map();
let cachedMdPilePosDimReducerRun = 0;
const getPilePositionByMdTransform = async (pileId) => {
const { dimensionalityReducer } = store.state;
if (
cachedMdPilePos.has(pileId) &&
lastMdReducerRun === cachedMdPilePosDimReducerRun
)
return cachedMdPilePos.get(pileId);
cachedMdPilePosDimReducerRun = lastMdReducerRun;
const uv = await dimensionalityReducer.transform([
aggregatedPileValues[pileId].flat(),
]);
const pilePos = layout.uvToXy(...uv[0]);
const pile = pileInstances.get(pileId);
if (pile.size === 1 && !cachedMdPilePos.has(pileId)) {
pile.items[0].item.setOriginalPosition(pilePos);
}
cachedMdPilePos.set(pileId, pilePos);
return pilePos;
};
const getPilePositionByData = (pileId, pileState) => {
const {
arrangementObjective,
arrangementOptions,
dimensionalityReducer,
} = store.state;
if (
arrangementObjective.length > 2 ||
arrangementOptions.forceDimReduction
) {
if (dimensionalityReducer) return getPilePositionByMdTransform(pileId);
return Promise.resolve([pileState.x, pileState.y]);
}
if (arrangementObjective.length > 1) {
return getPilePositionBy2dScales(pileId);
}
if (arrangementObjective.length) {
return getPilePositionBy1dOrdering(pileId);
}
console.warn(
"Can't arrange pile by data. No arrangement objective available."
);
return Promise.resolve([pileState.x, pileState.y]);
};
const getPilePositionByCoords = (pileState, objective) => {
if (objective.isCustom) {
return objective.property(pileState, pileState.index);
}
const { items } = store.state;
return objective.aggregator(
pileState.items.map((itemId) => objective.property(items[itemId]))
);
};
const getPilePosition = async (pileId, init) => {
const {
arrangementType,
arrangementObjective,
piles,
projector,
} = store.state;
const pile = pileInstances.get(pileId);
const pileState = piles[pileId];
const isUnpositioned = isPileUnpositioned(pileState);
const type =
init || isUnpositioned
? arrangementType || INITIAL_ARRANGEMENT_TYPE
: arrangementType;
const objective =
init || isUnpositioned
? arrangementObjective || INITIAL_ARRANGEMENT_OBJECTIVE
: arrangementObjective;
const ijToXy = (i, j) =>
layout.ijToXy(i, j, pile.width, pile.height, pile.offset);
if (type === 'data') return getPilePositionByData(pileId, pileState);
const pos = type && getPilePositionByCoords(pileState, objective);
switch (type) {
case 'index':
return ijToXy(...layout.idxToIj(pos));
case 'ij':
return ijToXy(...pos);
case 'xy':
return pos;
case 'uv':
return layout.uvToXy(...pos);
case 'custom':
if (projector) return projector(pos);
// eslint-disable-next-line no-fallthrough
default:
return Promise.resolve([pileState.x, pileState.y]);
}
};
/**
* Transform a point from screen to camera coordinates
* @param {array} point - Point in screen coordinates
* @return {array} Point in camera coordinates
*/
const transformPointToCamera = (point) => {
const v = toHomogeneous(point[0], point[1]);
vec4.transformMat4(v, v, camera.view);
return v.slice(0, 2);
};
/**
* Transform a point from camera to screen coordinates
* @param {array} point - Point in camera coordinates
* @return {array} Point in screen coordinates
*/
const transformPointFromCamera = (point) => {
const v = toHomogeneous(point[0], point[1]);
vec4.transformMat4(v, v, mat4.invert(scratch, camera.view));
return v.slice(0, 2);
};
/**
* Translate a point according to the camera position.
*
* @description This method is similar to `transformPointFromCamera` but it
* does not incorporate the zoom level. We use this method together with the
* search index as the search index is zoom-invariant.
*
* @param {array} point - Point to be translated
* @return {array} Translated point
*/
const translatePointFromCamera = (point) => [
point[0] - camera.translation[0],
point[1] - camera.translation[1],
];
const positionPiles = async (pileIds = [], { immideate = false } = {}) => {
const { arrangeOnGrouping, arrangementType, items } = store.state;
const positionAllPiles = !pileIds.length;
if (positionAllPiles) {
// eslint-disable-next-line no-param-reassign
pileIds = Object.keys(store.state.piles);
}
if (Object.keys(items).length === 0) {
createRBush();
updateScrollHeight();
renderRaf();
return;
}
const movingPiles = [];
const readyPiles = pileIds
.filter((id) => pileInstances.has(id))
.map((id) => pileInstances.get(id));
if (!readyPiles.length) return;
await arranging;
// eslint-disable-next-line no-restricted-syntax
for (const pile of readyPiles) {
// eslint-disable-next-line no-await-in-loop
const point = await getPilePosition(pile.id, isInitialPositioning);
lastPilePosition.set(pile.id, point);
const [x, y] = point;
if (immideate || isInitialPositioning) movePileToWithUpdate(pile, x, y);
movingPiles.push({ id: pile.id, x, y });
layout.numRows = Math.max(
layout.numRows,
Math.ceil(y / layout.rowHeight)
);
if (
isInitialPositioning ||
isPileUnpositioned(pile) ||
(arrangementType && !arrangeOnGrouping)
) {
renderedItems.get(pile.id).setOriginalPosition([x, y]);
}
}
isInitialPositioning = false;
const arrangementCancelActions = !arrangeOnGrouping
? getArrangementCancelActions()
: [];
store.dispatch(
batchActions([
createAction.movePiles(movingPiles),
...arrangementCancelActions,
])
);
if (positionAllPiles) createRBush();
updateScrollHeight();
renderRaf();
};
const positionPilesDb = debounce(positionPiles, POSITION_PILES_DEBOUNCE_TIME);
const positionItems = (pileId, { all = false } = {}) => {
const { piles, pileOrderItems } = store.state;
const pileInstance = pileInstances.get(pileId);
if (!pileInstance) return;
if (isFunction(pileOrderItems)) {
const pileState = piles[pileId];
pileInstance.setItemOrder(pileOrderItems(pileState));
}
pileInstance.positionItems(animator, { all });
};
const updatePileItemStyle = (pileState, pileId) => {
const {
items,
pileItemBrightness,
pileItemInvert,
pileItemOpacity,
pileItemTint,
} = store.state;
const pileInstance = pileInstances.get(pileId);
pileInstance.items.forEach((pileItem, i) => {
const itemState = items[pileItem.id];
pileItem.animateOpacity(
isFunction(pileItemOpacity)
? pileItemOpacity(itemState, i, pileState)
: pileItemOpacity
);
pileItem.image.invert(
isFunction(pileItemInvert)
? pileItemInvert(itemState, i, pileState)
: pileItemInvert
);
// We can't apply a brightness and invert effect as both rely on the same
// mechanism. Therefore we decide to give invert higher precedence.
if (!pileItemInvert) {
pileItem.image.brightness(
isFunction(pileItemBrightness)
? pileItemBrightness(itemState, i, pileState)
: pileItemBrightness
);
// We can't apply a brightness and tint effect as both rely on the same
// mechanism. Therefore we decide to give brightness higher precedence.
if (!pileItemBrightness) {
pileItem.image.tint(
isFunction(pileItemTint)
? pileItemTint(itemState, i, pileState)
: pileItemTint
);
}
}
});
};
const updatePileStyle = (pile, pileId) => {
const pileInstance = pileInstances.get(pileId);
if (!pileInstance) return;
const {
pileOpacity,
pileBorderSize,
pileScale,
pileSizeBadge,
pileVisibilityItems,
} = store.state;
pileInstance.animateOpacity(
isFunction(pileOpacity) ? pileOpacity(pile) : pileOpacity
);
pileInstance.animateScale(
isFunction(pileScale) ? pileScale(pile) : pileScale
);
pileInstance.setBorderSize(
isFunction(pileBorderSize) ? pileBorderSize(pile) : pileBorderSize
);
pileInstance.showSizeBadge(
isFunction(pileSizeBadge) ? pileSizeBadge(pile) : pileSizeBadge
);
pileInstance.setVisibilityItems(
isFunction(pileVisibilityItems)
? pileVisibilityItems(pile)
: pileVisibilityItems
);
renderRaf();
};
const createScaledImage = (texture) => {
const image = createImage(texture);
image.scale(getImageScaleFactor(image));
return image;
};
const updatePreviewStyle = (pileState) => {
const { previewRenderer, previewScaling } = store.state;
if (!previewRenderer) return;
const scaling = isFunction(previewScaling)
? previewScaling(pileState)
: previewScaling;
pileState.items.forEach((itemId) => {
const item = renderedItems.get(itemId);
if (!item.preview) return;
const scaleFactor = getImageScaleFactor(item.image);
const xScale = 1 + (scaleFactor * scaling[0] - 1);
const yScale = 1 + (scaleFactor * scaling[1] - 1);
item.preview.scaleX(xScale);
item.preview.scaleY(yScale);
});
};
const updateCover = (pileState, pileInstance) => {
const {
items,
coverRenderer,
coverAggregator,
pileCoverInvert,
pileCoverScale,
previewAggregator,
previewRenderer,
} = store.state;
const itemsOnPile = [];
const itemInstances = [];
pileState.items.forEach((itemId) => {
itemsOnPile.push(items[itemId]);
itemInstances.push(renderedItems.get(itemId));
});
pileInstance.setItems(itemInstances, {
asPreview: !!(previewAggregator || previewRenderer),
shouldDrawPlaceholder: true,
});
if (!coverRenderer) {
pileInstance.setCover(null);
positionItems(pileInstance.id, { all: true });
return;
}
const whenCoverImage = Promise.resolve(coverAggregator(itemsOnPile))
.then((aggregatedSrcs) => coverRenderer([aggregatedSrcs]))
.then(([coverTexture]) => {
const scaledImage = createScaledImage(coverTexture);
scaledImage.invert(
isFunction(pileCoverInvert)
? pileCoverInvert(pileState)
: pileCoverInvert
);
const extraScale = isFunction(pileCoverScale)
? pileCoverScale(pileState)
: pileCoverScale;
scaledImage.scale(scaledImage.scaleFactor * extraScale);
return scaledImage;
});
pileInstance.setCover(whenCoverImage);
whenCoverImage.then(() => {
positionItems(pileInstance.id);
updatePileBounds(pileInstance.id);
renderRaf();
});
};
const updatePreviewAndCover = (pileState, pileInstance) => {
if (pileState.items.length === 1) {
pileInstance.setCover(null);
positionItems(pileInstance.id);
pileInstance.setItems([renderedItems.get(pileState.items[0])]);
} else {
updatePreviewStyle(pileState);
updateCover(pileState, pileInstance);
}
};
const isDimReducerInUse = () => {
const { arrangementObjective, arrangementOptions } = store.state;
return (
arrangementObjective &&
(arrangementObjective.length > 2 || arrangementOptions.forceDimReduction)
);
};
const updatePileItems = (pileState, id) => {
if (pileInstances.has(id)) {
const pileInstance = pileInstances.get(id);
if (pileState.items.length === 0) {
deletePileHandler(id);
} else {
cachedMdPilePos.delete(id);
const itemInstances = pileState.items.map((itemId) =>
renderedItems.get(itemId)
);
if (store.state.coverAggregator) {
updatePreviewAndCover(pileState, pileInstance);
} else {
if (store.state.previewRenderer) {
updatePreviewStyle(pileState);
}
pileInstance.setItems(itemInstances);
positionItems(id);
}
if (itemInstances.length === 1 && isDimReducerInUse()) {
cachedMdPilePos.set(id, itemInstances[0].originalPosition);
}
updatePileBounds(id, { forceUpdate: true });
updatePileItemStyle(pileState, id);
}
} else {
createPileHandler(id, pileState);
}
};
const updatePilePosition = (pileState, id) => {
const pileInstance = pileInstances.get(id);
if (pileInstance) {
lastPilePosition.set(id, [pileState.x, pileState.y]);
return Promise.all([
animateMovePileTo(pileInstance, pileState.x, pileState.y),
new Promise((resolve) => {
function pileBoundsUpdateHandler(pileId) {
if (pileId === id) {
pubSub.unsubscribe('pileBoundsUpdate', this);
resolve();
}
}
pubSub.subscribe('pileBoundsUpdate', pileBoundsUpdateHandler);
}),
]);
}
return Promise.resolve();
};
const updateGridMat = (pileId) => {
const mat = ndarray(
new Uint16Array(new Array(layout.numColumns * layout.numRows).fill(0)),
[layout.numRows, layout.olNum]
);
gridMat = mat;
pileInstances.forEach((pile) => {
if (pile.id === pileId) return;
const minY = Math.floor(pile.bBox.minX / layout.columnWidth);
const minX = Math.floor(pile.bBox.minY / layout.rowHeight);
const maxY = Math.floor(pile.bBox.maxX / layout.columnWidth);
const maxX = Math.floor(pile.bBox.maxY / layout.rowHeight);
gridMat.set(minX, minY, 1);
gridMat.set(minX, maxY, 1);
gridMat.set(maxX, minY, 1);
gridMat.set(maxX, maxY, 1);
});
};
const next = (distanceMat, current) => {
let nextPos;
let minValue = Infinity;
// top
if (
current[0] - 1 >= 0 &&
distanceMat.get(current[0] - 1, current[1]) < minValue
) {
minValue = distanceMat.get(current[0] - 1, current[1]);
nextPos = [current[0] - 1, current[1]];
}
// left
if (
current[1] - 1 >= 0 &&
distanceMat.get(current[0], current[1] - 1) < minValue
) {
minValue = distanceMat.get(current[0], current[1] - 1);
nextPos = [current[0], current[1] - 1];
}
// bottom
if (
current[0] + 1 < distanceMat.shape[0] &&
distanceMat.get(current[0] + 1, current[1]) < minValue
) {
minValue = distanceMat.get(current[0] + 1, current[1]);
nextPos = [current[0] + 1, current[1]];
}
// right
if (
current[1] + 1 < distanceMat.shape[1] &&
distanceMat.get(current[0], current[1] + 1) < minValue
) {
minValue = distanceMat.get(current[0], current[1] + 1);
nextPos = [current[0], current[1] + 1];
}
const length = distanceMat.data.length;
distanceMat.set(current[0], current[1], length);
if (minValue === distanceMat.data.length) {
for (let i = 0; i < distanceMat.shape[0]; i++) {
for (let j = 0; j < distanceMat.shape[1]; j++) {
if (distanceMat.get(i, j) < minValue && distanceMat.get(i, j) > 0)
minValue = distanceMat.get(i, j);
nextPos = [i, j];
}
}
}
return nextPos;
};
const calcDist = (distanceMat, x, y, origin) => {
if (distanceMat.get(x, y) !== -1) return;
const distance = l2PointDist(x, y, origin[0], origin[1]);
distanceMat.set(x, y, distance);
};
const findDepilePos = (distanceMat, resultMat, origin, filterRowNum) => {
let current = [...origin];
let depilePos;
let count = 0;
while (!depilePos && count < distanceMat.data.length) {
// check current
if (resultMat.get(current[0], current[1]) < 1) depilePos = current;
if (!depilePos) {
// calc dist
// top
if (current[0] - 1 >= 0) {
calcDist(distanceMat, current[0] - 1, current[1], origin);
}
// left
if (current[1] - 1 >= 0) {
calcDist(distanceMat, current[0], current[1] - 1, origin);
}
// bottom
if (current[0] + 1 < distanceMat.shape[0]) {
calcDist(distanceMat, current[0] + 1, current[1], origin);
}
// right
if (current[1] + 1 < distanceMat.shape[1]) {
calcDist(distanceMat, current[0], current[1] + 1, origin);
}
// get closest cell
current = next(distanceMat, current);
count++;
}
}
// doesn't find an available cell
if (!depilePos) {
depilePos = [resultMat.shape[0] + 1, Math.floor(filterRowNum / 2)];
layout.numRows += filterRowNum;
updateScrollHeight();
}
return depilePos;
};
const convolveGridMat = (filterColNum, filterRowNum) => {
const filter = ndarray(
new Float32Array(new Array(filterColNum * filterRowNum).fill(1)),
[filterRowNum, filterColNum]
);
const resultMat = ndarray(
new Float32Array(
(layout.numRows - filterRowNum + 1) *
(layout.numColumns - filterColNum + 1)
),
[layout.numRows - filterRowNum + 1, layout.olNum - filterColNum + 1]
);
convolve(resultMat, gridMat, filter);
return resultMat;
};
const findPos = (origin, colNum, rowNum) => {
const resultMat = convolveGridMat(colNum, rowNum);
const distanceMat = ndarray(
new Float32Array(
new Array(
(layout.numRows - rowNum + 1) * (layout.numColumns - colNum + 1)
).fill(-1)
),
[layout.numRows - rowNum + 1, layout.numColumns - colNum + 1]
);
const depilePos = findDepilePos(distanceMat, resultMat, ori