UNPKG

piling.js

Version:

A WebGL-based Library for Visual Piling/Stacking

1,859 lines (1,535 loc) 149 kB
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