Commit 5c13e4ad authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Migrate algorithm for building buckets from points

parent 91029821
......@@ -4,11 +4,38 @@ import { getBoundingClientRect } from '../highlighter';
* @typedef {import('../../types/annotator').Anchor} Anchor
*/
/**
* A structured Array representing either the top (`startOrEnd` = 1) or the
* bottom (`startOrEnd` = -1) of an anchor's highlight bounding box.
*
* @typedef {[pixelPosition: number, startOrEnd: (-1 | 1), anchor: Anchor]} PositionPoint
*/
/**
* An object containing information about anchor highlight positions
*
* @typedef PositionPoints
* @prop {Anchor[]} above - Anchors that are offscreen above
* @prop {Anchor[]} below - Anchors that are offscreen below
* @prop {PositionPoint[]} points - PositionPoints for on-screen anchor
* highlights. Each highlight box has 2 PositionPoints (one for top edge
* and one for bottom edge).
*/
/**
* @typedef BucketInfo
* @prop {Array<Anchor[]>} buckets
* @prop {number[]} index - Array of (pixel) positions of each bucket
*/
// FIXME: Temporary duplication of size constants between here and BucketBar
const BUCKET_SIZE = 16; // Regular bucket size
const BUCKET_NAV_SIZE = BUCKET_SIZE + 6; // Bucket plus arrow (up/down)
const BUCKET_TOP_THRESHOLD = 115 + BUCKET_NAV_SIZE; // Toolbar
// TODO!! This is an option in the plugin right now
const BUCKET_GAP_SIZE = 60;
/**
* Find the closest valid anchor in `anchors` that is offscreen in the direction
* indicated.
......@@ -59,19 +86,6 @@ export function findClosestOffscreenAnchor(anchors, direction) {
return closestAnchor;
}
/**
* A structured Array representing either the top or the bottom of an anchor's
* highlight-box position.
* @typedef {[number, (-1 | 1), Anchor]} PositionPoint
*/
/**
* @typedef PositionPoints
* @prop {Anchor[]} above - Anchors that are offscreen above
* @prop {Anchor[]} below - Anchors that are offscreen below
* @prop {PositionPoint[]} points - Points representing the tops and bottoms
* of on-screen anchor highlight boxes
*/
/**
* Construct an Array of points representing the positional tops and bottoms
* of current anchor highlights. Each anchor whose highlight(s)' bounding
......@@ -124,3 +138,111 @@ export function constructPositionPoints(anchors) {
points,
};
}
/**
* Take a sorted set of `points` representing top and bottom positions of anchor
* highlights and group them into a collection of "buckets".
*
* @param {PositionPoint[]} points
* @return {BucketInfo}
*/
export function buildBuckets(points) {
const buckets = /** @type {Array<Anchor[]>} */ (new Array());
const bucketPositions = /** @type {number[]} */ (new Array());
// Anchors that are part of the currently-being-built bucket, and a correspon-
// ding count of unclosed top edges seen for that anchor
const current = /** @type {{anchors: Anchor[], counts: number[] }} */ ({
anchors: new Array(),
counts: new Array(),
});
points.forEach((point, index) => {
const [position, delta, anchor] = point;
// Does this point represent the top or the bottom of an anchor's highlight
// box?
const positionType = delta > 0 ? 'start' : 'end';
// See if this point's anchor is already in our working set of open anchors
const anchorIndex = current.anchors.indexOf(anchor);
if (positionType === 'start') {
if (anchorIndex === -1) {
// Add an entry for this anchor to our current set of "open" anchors
current.anchors.unshift(anchor);
current.counts.unshift(1);
} else {
// Increment the number of times we've seen a start/top edge for this
// anchor
current.counts[anchorIndex]++;
}
} else {
// positionType = 'end'
// This is the bottom/end of an anchor that we should have already seen
// a top edge for. Decrement the count, representing that we've found an
// end point to balance a previously-seen start point
current.counts[anchorIndex]--;
if (current.counts[anchorIndex] === 0) {
// All start points for this anchor have been balanced by end point(s)
// So we can remove this anchor from our collection of open anchors
current.anchors.splice(anchorIndex, 1);
current.counts.splice(anchorIndex, 1);
}
}
// For each point, we'll either:
// * create a new bucket: Add a new bucket (w/corresponding bucket position)
// and add the working anchors to the new bucket. This, of course, has
// the effect of making the buckets collection larger. OR:
// * merge buckets: In most cases, merge the anchors from the last bucket
// into the penultimate (previous) bucket and remove the last bucket (and
// its corresponding `bucketPosition` entry). Also add the working anchors
// to the previous bucket. Note that this decreases the size of the
// buckets collection.
// The ultimate set of buckets is defined by the pattern of creating and
// merging/removing buckets as we iterate over points.
const isFirstOrLastPoint =
bucketPositions.length === 0 || index === points.length - 1;
const isLargeGap =
bucketPositions.length &&
position - bucketPositions[bucketPositions.length - 1] > BUCKET_GAP_SIZE;
if (current.anchors.length === 0 || isFirstOrLastPoint || isLargeGap) {
// Create a new bucket, because:
// - There are no more open/working anchors, OR
// - This is the first or last point, OR
// - There's been a large dimensional gap since the last bucket's position
buckets.push(current.anchors.slice());
// Each bucket gets a corresponding entry in `bucketPositions` for the
// pixel position of its eventual indicator in the bucket bar
bucketPositions.push(position);
} else {
// Merge buckets
// We will remove 2 (usually) or 1 (if there is only one) bucket
// from the buckets collection, and re-add 1 merged bucket (always)
// Always pop off the last bucket
const ultimateBucket = buckets.pop() || new Array();
// If there is a previous/penultimate bucket, pop that off, as well
let penultimateBucket = new Array();
if (buckets[buckets.length - 1]?.length) {
penultimateBucket = buckets.pop() || new Array();
// Because we're removing two buckets but only re-adding one below,
// we'll end up with a misalignment in the `bucketPositions` collection.
// Remove the last entry here, as it corresponds to the ultimate bucket,
// which won't be re-added in its present form
bucketPositions.pop();
}
// Create a merged bucket from the anchors in the penultimate bucket
// (when available), ultimate bucket and current working anchors
const activeBucket = Array.from(
new Set([...penultimateBucket, ...ultimateBucket, ...current.anchors])
);
// Push the now-merged bucket onto the buckets collection
buckets.push(activeBucket);
}
});
return { buckets, index: bucketPositions };
}
......@@ -3,7 +3,7 @@ $ = require('jquery')
scrollIntoView = require('scroll-into-view')
{ findClosestOffscreenAnchor, constructPositionPoints } = require('./bucket-bar-js')
{ findClosestOffscreenAnchor, constructPositionPoints, buildBuckets } = require('./bucket-bar-js')
highlighter = require('../highlighter')
......@@ -82,6 +82,7 @@ module.exports = class BucketBar extends Delegator
_update: ->
{above, below, points } = constructPositionPoints(@annotator.anchors)
fakeBuckets = buildBuckets(points)
# Accumulate the overlapping anchors into buckets.
# The algorithm goes like this:
......@@ -134,8 +135,8 @@ module.exports = class BucketBar extends Delegator
else
last = buckets[buckets.length-1]
toMerge = []
last.push a0 for a0 in carry.anchors when a0 not in last
last.push a0 for a0 in toMerge when a0 not in last
last.push a0 for a0 in carry.anchors when a0 not in last
{buckets, index, carry}
,
......@@ -146,6 +147,10 @@ module.exports = class BucketBar extends Delegator
counts: []
latest: 0
@buckets.forEach (bucket, bi) =>
console.assert(@index[bi] is fakeBuckets.index[bi], 'index positions are equal')
bucket.forEach (anchor, ai) =>
console.assert(anchor is fakeBuckets.buckets[bi][ai], 'anchors are the same', anchor.annotation.$tag, fakeBuckets.buckets[bi][ai].annotation.$tag)
# Scroll up
@buckets.unshift [], above, []
@index.unshift 0, BUCKET_TOP_THRESHOLD - 1, BUCKET_TOP_THRESHOLD
......
import {
findClosestOffscreenAnchor,
constructPositionPoints,
buildBuckets,
} from '../bucket-bar-js';
import { $imports } from '../bucket-bar-js';
......@@ -199,4 +200,76 @@ describe('annotator/plugin/bucket-bar-js', () => {
}
});
});
describe('buildBuckets', () => {
it('should return empty buckets if points array is empty', () => {
const bucketInfo = buildBuckets([]);
assert.isArray(bucketInfo.buckets);
assert.isEmpty(bucketInfo.buckets);
assert.isEmpty(bucketInfo.index);
});
it('should group overlapping anchor highlights into shared buckets', () => {
const anchors = [{}, {}, {}, {}];
const points = [];
// Represents points for 4 anchors that all have a top of 150px and bottom
// of 200
anchors.forEach(anchor => {
points.push([150, 1, anchor]);
points.push([200, -1, anchor]);
});
const buckets = buildBuckets(points);
assert.equal(buckets.buckets.length, 2);
assert.isEmpty(buckets.buckets[1]);
// All anchors are in a single bucket
assert.deepEqual(buckets.buckets[0], anchors);
// Because this is the first bucket, it will be aligned top
assert.equal(buckets.index[0], 150);
});
it('should group nearby anchor highlights into shared buckets', () => {
let increment = 25;
const anchors = [{}, {}, {}, {}];
const points = [];
// Represents points for 4 anchors that all have different start and
// end positions, but only differing by 25px
anchors.forEach(anchor => {
points.push([150 + increment, 1, anchor]);
points.push([200 + increment, -1, anchor]);
increment += 25;
});
const buckets = buildBuckets(points);
assert.equal(buckets.buckets.length, 2);
assert.isEmpty(buckets.buckets[1]);
// All anchors are in a single bucket
assert.deepEqual(buckets.buckets[0], anchors);
// Because this is the first bucket, it will be aligned top
assert.equal(buckets.index[0], 175);
});
it('should put anchors that are not near each other in separate buckets', () => {
let position = 100;
const anchors = [{}, {}, {}, {}];
const points = [];
// Represents points for 4 anchors that all have different start and
// end positions, but only differing by 25px
anchors.forEach(anchor => {
points.push([position, 1, anchor]);
points.push([position + 20, -1, anchor]);
position += 100;
});
const buckets = buildBuckets(points);
assert.equal(buckets.buckets.length, 8);
// Legacy of previous implementation, shrug?
assert.isEmpty(buckets.buckets[1]);
assert.isEmpty(buckets.buckets[3]);
assert.isEmpty(buckets.buckets[5]);
assert.isEmpty(buckets.buckets[7]);
assert.deepEqual(buckets.buckets[0], [anchors[0]]);
assert.deepEqual(buckets.buckets[2], [anchors[1]]);
assert.deepEqual(buckets.buckets[4], [anchors[2]]);
assert.deepEqual(buckets.buckets[6], [anchors[3]]);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment