/**
* @module line
*/
import * as common from './common.mjs';
import * as colorMod from './color.mjs';
/**
* Brush event
*
* @event brush
* @type {object}
* @property {number} x1
* @property {number} x2
* @property {boolean} internal - Was the event triggered internally by pointer events
* @property {module:common.Chart} chart
*/
/**
* @typedef {object} LineChartOptions
* @property {boolean} [hidePoints] - Hide the data marker points on the line
* @property {object} [brush] - Brush (i.e. selection) options
* @property {boolean} [brush.disabled]
* @property {"data"|"visual"} [brush.type="data"] - Brush selection will anchor to data or visual coordinates
* @property {boolean} [brush.showTooltip] - Allow showing tooltip when actively brushing
* @property {boolean} [brush.disableZoom] - Disable auto-zoom when brush finishes
* @property {boolean} [brush.clipMask] - Visually clip the brush selection to the data area
* @property {boolean} [brush.passive] - Do not allow pointer interaction
*/
/**
* A visual block highlight for a line chart. Essentially a way to colorize regions of
* line chart.
*
* @typedef {object} LineChartSegment
* @property {number} x - The left x data value to begin this segment at
* @property {number} width - Width in data value units
* @property {module:color~ColorParsable} [color] - Fill color or gradient for region
* @property {number} [y=yMax] - The top y data value to begin the segment at
* @property {number} [height=yMax-yMin] - Height in data value units
*/
/**
* A Line Chart
*
* @extends module:common.Chart
* @param {LineChartOptions|module:common~ChartOptions} [options]
* @emits brush
*
* @example
*
* const sl = new line.LineChart({
* el: document.body
* });
* sl.setData([0,10,20,10,0,-10,-20,-10,0,10,20,10,0]);
*
*/
export class LineChart extends common.Chart {
init(options={}) {
this.hidePoints = options.hidePoints;
this.brush = options.brush ?? {};
this.brush.type ??= 'data';
this.segments = [];
this._segmentEls = new Map();
this._segmentFills = new Map();
this._gcQueue = [];
this._brushState = {};
this.onPointerDownForBrush = this.onPointerDownForBrush.bind(this);
}
/**
* Set the horizontal segments for this data. A segment is data clipped `rect` that can be
* stylized independently to give special meaning to a data range.
*
* @param {Array<module:line~LineChartSegment>} segments
* @param {object} [options]
* @param {boolean} [options.render=true] - Set to false to prevent rendering immediately
*/
setSegments(segments, options={}) {
this.segments = segments;
if (options.render !== false) {
this.render();
}
}
beforeSetElement(el) {
const old = this.el;
if (old) {
old.removeEventListener('pointerdown', this.onPointerDownForBrush);
}
el.classList.add('sc-linechart');
}
afterSetElement(el) {
if (this._bgGradient) {
this.removeGradient(this._bgGradient);
}
const fill = colorMod.parse(this.getColor());
this._bgGradient = this.addGradient({
type: 'linear',
colors: [
fill.adjustAlpha(-0.7).adjustLight(-0.2),
fill.adjustAlpha(-0.14),
]
});
const areaClipId = `clip-${this.id}`;
const lineClipId = `line-clip-${this.id}`;
const markerLineClipId = `marker-line-clip-${this.id}`;
const markerId = `marker-${this.id}`;
const markerSize = 40; // Abstract units
const defs = common.createSVG({
name: 'defs',
children: [{
name: 'clipPath',
id: lineClipId,
children: [{
name: 'rect',
class: ['sc-line-clip']
}]
}, {
name: 'clipPath',
id: markerLineClipId,
children: [{
name: 'rect',
class: ['sc-marker-line-clip']
}]
}, {
name: 'clipPath',
id: areaClipId,
children: [{
name: 'path',
class: ['sc-data', 'sc-area']
}]
}, this.hidePoints ? undefined : {
name: 'marker',
id: markerId,
class: 'sc-line-marker',
attrs: {
markerUnits: 'userSpaceOnUse',
refX: markerSize / 2,
refY: markerSize / 2,
markerHeight: markerSize,
markerWidth: markerSize,
},
children: [{
name: 'circle',
class: 'sc-dot',
attrs: {
cx: markerSize / 2,
cy: markerSize / 2,
}
}]
}].filter(x => x)
});
this._backgroundEl = common.createSVG({
name: 'g',
class: 'sc-background',
attrs: {
'clip-path': `url(#${areaClipId})`,
},
children: [{
name: 'rect',
class: 'sc-visual-data-area',
attrs: {
fill: `url(#${this._bgGradient.id})`,
},
}]
});
this._lineEl = common.createSVG({
name: 'path',
class: ['sc-data', 'sc-line', 'sc-visual-data-line'],
attrs: {
'clip-path': `url(#${lineClipId})`,
}
});
const plotRegionChildren = [defs, this._backgroundEl, this._lineEl];
if (!this.hidePoints) {
this._markerLineEl = common.createSVG({
name: 'path',
class: ['sc-data', 'sc-line', 'sc-visual-data-line-markers'],
attrs: {
'clip-path': `url(#${markerLineClipId})`,
'marker-start': `url(#${markerId})`,
'marker-mid': `url(#${markerId})`,
'marker-end': `url(#${markerId})`,
}
});
plotRegionChildren.push(this._markerLineEl);
} else {
this._markerLineEl = undefined;
}
if (!this.brush.disabled) {
const groupEl = common.createSVG({name: 'g', class: ['sc-brush']});
const maskAttrs = {};
if (this.brush.clipMask) {
maskAttrs['clip-path'] = `url(#${areaClipId})`;
}
this._brushMaskEl = common.createSVG({
name: 'rect',
class: ['sc-brush-mask'],
attrs: maskAttrs,
});
groupEl.append(this._brushMaskEl);
if (!this.brush.passive) {
this._brushHandleStartEl = common.createSVG({
name: 'rect',
class: ['sc-brush-handle', 'sc-brush-start']
});
this._brushHandleEndEl = common.createSVG({
name: 'rect',
class: ['sc-brush-handle', 'sc-brush-end']
});
groupEl.append(this._brushHandleStartEl, this._brushHandleEndEl);
el.addEventListener('pointerdown', this.onPointerDownForBrush);
} else {
groupEl.classList.add('sc-passive');
}
plotRegionChildren.push(groupEl);
}
this._plotRegionEl.prepend(...plotRegionChildren);
this._areaEl = defs.querySelector('path.sc-area');
}
doReset() {
this._prevCoords = null;
this._prevData = null;
this._lineEl.removeAttribute('d');
if (this._markerLineEl) {
this._markerLineEl.removeAttribute('d');
}
this._areaEl.removeAttribute('d');
for (const x of this._segmentEls.values()) {
x.remove();
}
this._segmentEls.clear();
for (const x of this._segmentFills.values()) {
this.removeGradient(x.gradient);
}
this._segmentFills.clear();
}
adjustScale(manifest) {
super.adjustScale(manifest);
const data = manifest.data;
this._xMin ??= data[0].x;
this._xMax ??= data[data.length - 1].x;
if (this._xMax === this._xMin) {
this._xMin -= 0.5;
this._xMax += 0.5;
}
if (this._yMax === this._yMin) {
this._yMin -= 0.5;
this._yMax += 0.5;
}
}
doLayout(manifest, options) {
const layouts = this._renderBeforeLayout(manifest, options);
this._renderDoLayout(layouts);
this._prevCoords = layouts.coords;
this._prevData = manifest.data;
}
_renderBeforeLayout({data}, {disableAnimation}) {
const coords = data.map(o => [this.xValueToCoord(o.x), this.yValueToCoord(o.y)]);
let forceLayout = false;
if (!disableAnimation && this._prevCoords) {
// We can use CSS to animate the transition but we have to use a little hack
// because it only animates when the path has the same number (or more) points.
if (this._prevCoords.length !== coords.length) {
const identityIdx = data.length / 2 | 0;
const identity = data[identityIdx];
const prevIdentityIdx = this._prevData.findIndex(o =>
o.x === identity.x && o.y === identity.y);
const ltr = prevIdentityIdx === -1 || identityIdx <= prevIdentityIdx;
const prev = Array.from(this._prevCoords);
if (ltr) {
while (prev.length > coords.length) {
prev.shift();
}
while (prev.length < coords.length) {
prev.push(prev[prev.length - 1]);
}
} else {
while (prev.length > coords.length) {
prev.pop();
}
while (prev.length < coords.length) {
prev.unshift(prev[0]);
}
}
const pathLine = this.makePath(prev);
this._lineEl.setAttribute('d', pathLine);
if (this._markerLineEl) {
this._markerLineEl.setAttribute('d', pathLine);
}
this._areaEl.setAttribute('d', this.makePath(prev, {closed: true}));
forceLayout = true;
}
}
const segmentAdds = [];
const segmentUpdates = [];
const segmentRemoves = [];
const gradientRemoves = [];
if (this.segments.length || this._segmentEls.size || this._segmentFills.size) {
const unclaimedSegmentEls = new Map(this._segmentEls);
const unclaimedFills = new Map(this._segmentFills);
const plot1ThirdX = this._plotWidth / 3 + this._plotBox[3];
const plot3ThirdX = this._plotBox[1] - this._plotWidth / 3;
for (let i = 0; i < this.segments.length; i++) {
const s = this.segments[i];
unclaimedSegmentEls.delete(s);
const x = s.x != null ? this.xValueToCoord(s.x) : this._plotBox[3];
const width = s.width != null ?
this.xValueScale(s.width) :
this._plotWidth - (x - this._plotBox[3]);
let el = this._segmentEls.get(s);
if (x >= this._plotBox[1] || x + width <= this._plotBox[3]) {
if (el) {
segmentUpdates.push({el, x: x + width / 2, width: 0});
}
continue;
}
const y = s.y != null ? this.yValueToCoord(s.y) : this._plotBox[0];
const height = s.height != null ?
this.yValueScale(s.height) :
this._plotHeight - (y - this._plotBox[0]);
if (y >= this._plotBox[2] || y + height <= this._plotBox[0]) {
if (el) {
segmentUpdates.push({el, y: y + height / 2, height: 0});
}
continue;
}
if (!el) {
const centerX = x + width / 2;
const xOfft = centerX >= plot3ThirdX ? width : centerX <= plot1ThirdX ? 0 : width / 2;
const constrainedX = Math.max(this._plotBox[3], Math.min(this._plotBox[1], x + xOfft));
const constrainedY = Math.max(this._plotBox[0], Math.min(this._plotBox[2], y));
const constrainedHeight = Math.max(0, Math.min(this._plotBox[2] - constrainedY, height));
el = common.createSVG({
name: 'rect',
class: ['sc-visual-data-segment'],
attrs: {
x: constrainedX,
y: constrainedY,
width: 0,
height: constrainedHeight,
}
});
this._segmentEls.set(s, el);
segmentAdds.push({el});
if (!disableAnimation && this._prevCoords) {
forceLayout = true;
}
}
let gradient;
if (s.color) {
if (!this._segmentFills.has(s.color)) {
const fill = colorMod.parse(s.color);
gradient = this.addGradient((fill instanceof colorMod.Gradient) ? fill : {
type: 'linear',
colors: [
fill.adjustAlpha(-0.7).adjustLight(-0.2),
fill.adjustAlpha(-0.14),
]
});
this._segmentFills.set(s.color, {gradient});
} else {
unclaimedFills.delete(s.color);
gradient = this._segmentFills.get(s.color).gradient;
}
}
segmentUpdates.push({el, x, y, width, height, gradient});
}
if (unclaimedSegmentEls.size) {
for (const [k, el] of unclaimedSegmentEls) {
const x = Number(el.getAttribute('x'));
const width = Number(el.getAttribute('width'));
segmentRemoves.push({el, x: x + width / 2});
this._segmentEls.delete(k);
}
}
if (unclaimedFills.size) {
for (const [k, {gradient}] of unclaimedFills) {
this._segmentFills.delete(k);
gradientRemoves.push(gradient);
}
}
}
return {forceLayout, coords, segmentAdds, segmentUpdates, segmentRemoves, gradientRemoves};
}
_renderDoLayout({coords, forceLayout, segmentAdds, segmentUpdates, segmentRemoves, gradientRemoves}) {
for (let i = 0; i < segmentAdds.length; i++) {
this._backgroundEl.append(segmentAdds[i].el);
}
if (forceLayout) {
this._plotRegionEl.clientWidth;
}
const linePath = this.makePath(coords);
this._lineEl.setAttribute('d', linePath);
if (this._markerLineEl) {
this._markerLineEl.setAttribute('d', linePath);
}
this._areaEl.setAttribute('d', this.makePath(coords, {closed: true}));
for (let i = 0; i < segmentUpdates.length; i++) {
const o = segmentUpdates[i];
const x = o.x ?? o.el.getAttribute('x');
const y = o.y ?? o.el.getAttribute('y');
const constrainedX = Math.max(this._plotBox[3], Math.min(this._plotBox[1], x));
const constrainedY = Math.max(this._plotBox[0], Math.min(this._plotBox[2], y));
if (o.x !== undefined) {
o.el.setAttribute('x', constrainedX);
}
if (o.y !== undefined) {
o.el.setAttribute('y', constrainedY);
}
if (o.width !== undefined) {
const width = o.width - (constrainedX - x);
o.el.setAttribute('width', Math.max(0, Math.min(this._plotBox[1] - constrainedX, width)));
}
if (o.height !== undefined) {
const height = o.height - (constrainedY - y);
o.el.setAttribute('height', Math.max(0, Math.min(this._plotBox[2] - constrainedY, height)));
}
if (o.gradient) {
o.el.setAttribute('fill', `url(#${o.gradient.id})`);
}
}
if (segmentRemoves.length) {
for (const {el, x} of segmentRemoves) {
el.setAttribute('x', x);
el.setAttribute('width', 0);
}
this._gcQueue.push(
[document.timeline.currentTime, () => segmentRemoves.forEach(({el}) => el.remove())]);
}
if (gradientRemoves.length) {
this._gcQueue.push(
[document.timeline.currentTime, () => gradientRemoves.forEach(x => this.removeGradient(x))]);
}
if (this._gcQueue.length) {
this._schedGC();
}
}
/**
* Show the current brush
*/
showBrush() {
const state = this._brushState;
if (state.visible) {
return;
}
this._plotRegionEl.classList.add('sc-brush-visible');
state.visible = true;
}
/**
* Hide the current brush. Any active pointer interaction will be cancelled.
*/
hideBrush() {
const state = this._brushState;
if (state.pointerAborter && !state.pointerAborter.signal.aborted) {
state.pointerAborter.abort();
}
if (!state.visible) {
return;
}
this._plotRegionEl.classList.remove('sc-brush-visible');
state.visible = false;
}
/**
* Set the brush parameters. I.e. Mark a selection on the chart.
* Will also show the brush if not currently visible.
*
* @param {object} options
* @param {"data"|"visual"} [options.type="data"] - Indicate what unit type the x1 and x2 options are
* @param {number} [options.x1] - Starting x value/coordinate
* @param {number} [options.x2] - Ending x value/coordinate
*/
setBrush(options) {
this._establishBrushState();
this._setBrush(options);
if (this._brushState.x1 != null || this._brushState.x2 != null) {
this.showBrush();
} else {
this.hideBrush();
}
}
_setBrush({x1, x2, type=this.brush.type, _internal=false}) {
const state = this._brushState;
if (x1 !== undefined) {
state.x1 = (type === this.brush.type || x1 === null) ? x1 :
type === 'data' ?
this.xValueToCoord(x1) :
this.xCoordToValue(x1);
} else if (state.x1 == null) {
throw new Error('missing x1 state');
}
if (x2 !== undefined) {
state.x2 = (type === this.brush.type || x2 === null) ? x2 :
type === 'data' ?
this.xValueToCoord(x2) :
this.xCoordToValue(x2);
} else if (state.x2 == null) {
throw new Error('missing x2 state');
}
cancelAnimationFrame(state.updateBrushAnimFrame);
state.updateBrushAnimFrame = requestAnimationFrame(() => this._updateBrush());
queueMicrotask(() => this.dispatchEvent(new CustomEvent('brush', {
detail: {
x1: state.x1,
x2: state.x2,
type: this.brush.type,
internal: _internal,
chart: this,
}
})));
}
onPointerDownForBrush(ev) {
const state = this._establishBrushState();
if (state.active) {
return;
}
const xCoord = (ev.pageX - state.chartOffsets[0]) * this.devicePixelRatio;
const yCoord = (ev.pageY - state.chartOffsets[1]) * this.devicePixelRatio;
if (xCoord < this._plotBox[3] || xCoord > this._plotBox[1] ||
yCoord < this._plotBox[0] || yCoord > this._plotBox[2]) {
return;
}
const charts = this.brush.shared ? this.getAllCharts()
.filter(x => x.brush && x.brush.shared) : [this];
for (const x of charts) {
x._establishBrushState({active: true});
}
const suspendedTooltips = [];
if (!this.brush.showTooltip) {
const root = this.parent ?? this;
for (const view of root._tooltipViews.values()) {
if (view.options.pointerEvents) {
this._suspendTooltipView(view);
suspendedTooltips.push(view);
}
}
}
this.el.classList.add('sc-brushing');
let handle;
let isNew;
if (ev.target.classList.contains('sc-brush-mask')) {
this.el.classList.add('sc-moving');
handle = '*';
} else {
this.el.classList.add('sc-sizing');
if (ev.target.classList.contains('sc-brush-start')) {
handle = 'start';
} else {
handle = 'end';
isNew = !ev.target.classList.contains('sc-brush-end');
}
}
for (const chart of charts) {
const s = chart._brushState;
s.handle = handle;
s.xAnchor = xCoord;
if (isNew) {
// Reset brush to 0 width on our pointer position
s.x1 = s.x2 = (chart.brush.type === 'data' ? chart.xCoordToValue(xCoord) : xCoord);
}
// While brushing with an active pointer our brush is always visual...
if (chart.brush.type === 'data') {
s.pointerX1 = chart.xValueToCoord(s.x1);
s.pointerX2 = chart.xValueToCoord(s.x2);
} else {
s.pointerX1 = s.x1;
s.pointerX2 = s.x2;
}
if (handle === 'start') {
s.pointerX1 = xCoord;
} else if (handle === 'end') {
s.pointerX2 = xCoord;
}
}
const pointerAborter = state.pointerAborter = new AbortController();
const signal = pointerAborter.signal;
signal.addEventListener('abort', () => {
this.el.classList.remove('sc-brushing', 'sc-sizing', 'sc-moving');
const hide = state.x1 === state.x2;
for (const chart of charts) {
const s = chart._brushState;
s.active = false;
if (!chart.brush.disableZoom) {
chart.hideBrush();
if (hide) {
chart.setZoom();
} else {
const xMin = s.x2 > s.x1 ? s.x1 : s.x2;
const xMax = s.x1 < s.x2 ? s.x2 : s.x1;
if (chart.brush.type === 'data') {
chart.setZoom({xRange: [xMin, xMax], type: 'data', _internal: true});
} else if (chart.brush.type === 'visual') {
const scale = [1, 1];
const translate = [0, 0];
if (chart._zoomState.active && chart._zoomState.type === 'visual') {
if (chart._zoomState.scale) {
scale[0] = chart._zoomState.scale[0];
scale[1] = chart._zoomState.scale[1];
}
if (chart._zoomState.translate) {
translate[0] = chart._zoomState.translate[0];
translate[1] = chart._zoomState.translate[1];
}
}
// Translate is always in pre-scaled coordinates coordinates..
translate[0] += (xMin - chart._plotBox[3]) / scale[0];
scale[0] *= this._plotWidth / (xMax - xMin);
chart.setZoom({scale, translate, type: 'visual', _internal: true});
} else {
throw new TypeError("invalid brush type");
}
}
} else if (hide) {
chart.hideBrush();
}
if (hide) {
chart._setBrush({
x1: null,
x2: null,
_internal: true
});
}
}
for (const x of suspendedTooltips) {
this._resumeTooltipView(x);
}
});
// Cancel-esc pointer events are sloppy and unreliable (proven). Kitchen sink...
addEventListener('pointercancel', () => pointerAborter.abort(), {signal});
addEventListener('pointerup', () => pointerAborter.abort(), {signal});
addEventListener('pointermove', ev => {
const xCoord = (ev.pageX - state.chartOffsets[0]) * this.devicePixelRatio;
for (let i = 0; i < charts.length; i++) {
const chart = charts[i];
const s = chart._brushState;
if (s.handle === '*') {
const minX = Math.min(s.pointerX1, s.pointerX2);
const maxX = Math.max(s.pointerX1, s.pointerX2);
let d = xCoord - s.xAnchor;
if (d < 0) {
if (minX + d < chart._plotBox[3]) {
d = chart._plotBox[3] - minX;
}
} else if (maxX + d > chart._plotBox[1]) {
d = chart._plotBox[1] - maxX;
}
s.pointerX1 += d;
s.pointerX2 += d;
s.xAnchor += d;
} else {
const boundXCoord = Math.max(chart._plotBox[3], Math.min(chart._plotBox[1], xCoord));
if (s.handle === 'start') {
s.pointerX1 = boundXCoord;
} else {
s.pointerX2 = boundXCoord;
}
}
chart._setBrush({
x1: s.handle !== 'end' ? s.pointerX1 : undefined,
x2: s.handle !== 'start' ? s.pointerX2 : undefined,
type: 'visual',
_internal: true
});
}
}, {signal});
for (const chart of charts) {
const s = chart._brushState;
chart._setBrush({
x1: s.pointerX1,
x2: s.pointerX2,
type: 'visual',
_internal: true
});
chart.showBrush();
}
}
_updateBrush() {
let {x1, x2} = this._brushState;
if (x1 == null || x2 == null) {
return;
}
if (this.brush.type === 'data') {
x1 = this.xValueToCoord(x1);
x2 = this.xValueToCoord(x2);
}
if (this.brush.snap) {
x1 = this.xValueToCoord(this.findNearestFromXCoord(x1)?.x);
x2 = this.xValueToCoord(this.findNearestFromXCoord(x2)?.x);
}
if (x1 === null || x2 === null || isNaN(x1) || isNaN(x2)) {
return;
}
const reversed = x1 > x2;
if (reversed) {
[x1, x2] = [x2, x1];
}
if (x1 < this._plotBox[3]) {
x1 = this._plotBox[3];
} else if (x1 > this._plotBox[1]) {
x1 = this._plotBox[1];
}
if (x2 > this._plotBox[1]) {
x2 = this._plotBox[1];
} else if (x2 < this._plotBox[3]) {
x2 = this._plotBox[3];
}
const top = this._plotBox[0];
const height = this._plotBox[2] - top;
const handleWidth = Math.max(1, Math.min(this.brush.handleWidth ?? 4, x2 - x1));
this._brushMaskEl.setAttribute('height', height);
this._brushMaskEl.setAttribute('width', x2 - x1);
this._brushMaskEl.setAttribute('y', top);
this._brushMaskEl.setAttribute('x', x1);
if (!this.brush.passive) {
this._brushHandleStartEl.setAttribute('height', height);
this._brushHandleEndEl.setAttribute('height', height);
this._brushHandleStartEl.setAttribute('width', handleWidth);
this._brushHandleEndEl.setAttribute('width', handleWidth);
this._brushHandleStartEl.setAttribute('y', top);
this._brushHandleEndEl.setAttribute('y', top);
this._brushHandleStartEl.setAttribute('x', !reversed ? x1 : x2 - handleWidth);
this._brushHandleEndEl.setAttribute('x', !reversed ? x2 - handleWidth : x1);
}
}
_establishBrushState(extra) {
const chartRect = this.el.getBoundingClientRect();
Object.assign(this._brushState, {
chartOffsets: [chartRect.x + scrollX, chartRect.y + scrollY],
}, extra);
return this._brushState;
}
afterRender(...args) {
super.afterRender(...args);
if (this._brushState.active) {
this._setBrush({
x1: this._brushState.handle !== 'end' ? this._brushState.pointerX1 : undefined,
x2: this._brushState.handle !== 'start' ? this._brushState.pointerX2 : undefined,
type: 'visual',
_internal: true
});
} else if (this._brushState.visible) {
this._updateBrush();
}
}
adjustSize(...args) {
super.adjustSize(...args);
if (this._brushState.visible) {
this._updateBrush();
}
}
_schedGC() {
if (this._gcTimeout) {
return;
}
this._gcTimeout = setTimeout(() => {
common.requestIdle(() => {
this._gcTimeout = null;
this._gc();
});
}, 1100);
}
_gc() {
const animDur = common.getStyleValue(this.el, '--transition-duration', 'time') || 0;
const expiration = document.timeline.currentTime - animDur - 100;
for (const [ts, cb] of Array.from(this._gcQueue)) {
if (ts > expiration) {
break;
}
this._gcQueue.shift();
try {
cb();
} catch(e) {
console.error('Garbage collection error:', e);
}
}
if (this._gcQueue.length) {
setTimeout(() => this._schedGC(), this._gcQueue[0][0] - expiration);
}
}
}