diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/base/content/glodaFacetVis.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/content/glodaFacetVis.js | 428 |
1 files changed, 428 insertions, 0 deletions
diff --git a/comm/mail/base/content/glodaFacetVis.js b/comm/mail/base/content/glodaFacetVis.js new file mode 100644 index 0000000000..0060f67d92 --- /dev/null +++ b/comm/mail/base/content/glodaFacetVis.js @@ -0,0 +1,428 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Facet visualizations that would be awkward in XBL. Allegedly because the + * interaciton idiom of a protovis-based visualization is entirely different + * from XBL, but also a lot because of the lack of good syntax highlighting. + */ + +/* import-globals-from glodaFacetView.js */ +/* import-globals-from protovis-r2.6-modded.js */ + +/** + * A date facet visualization abstraction. + */ +function DateFacetVis(aBinding, aCanvasNode) { + this.binding = aBinding; + this.canvasNode = aCanvasNode; + + this.faceter = aBinding.faceter; + this.attrDef = this.faceter.attrDef; +} +DateFacetVis.prototype = { + build() { + let resultsBarRect = document + .getElementById("results") + .getBoundingClientRect(); + this.allowedSpace = resultsBarRect.right - resultsBarRect.left; + this.render(); + }, + rebuild() { + this.render(); + }, + + _MIN_BAR_SIZE_PX: 9, + _BAR_SPACING_PX: 1, + + _MAX_BAR_SIZE_PX: 44, + + _AXIS_FONT: "10px sans-serif", + _AXIS_HEIGHT_NO_LABEL_PX: 6, + _AXIS_HEIGHT_WITH_LABEL_PX: 14, + _AXIS_VERT_SPACING_PX: 1, + _AXIS_HORIZ_MIN_SPACING_PX: 4, + + _MAX_DAY_COUNT_LABEL_DISPLAY: 10, + + /** + * Figure out how to chunk things given the linear space in pixels. In an + * ideal world we would not use pixels, avoiding tying ourselves to assumed + * pixel densities, but we do not live there. Reality wants crisp graphics + * and does not have enough pixels that you can ignore the pixel coordinate + * space and have things still look sharp (and good). + * + * Because of our love of sharpness, we will potentially under-use the space + * allocated to us. + * + * @param aPixels The number of linear content pixels we have to work with. + * You are in charge of the borders and such, so you subtract that off + * before you pass it in. + * @returns An object with attributes: + */ + makeIdealScaleGivenSpace(aPixels) { + let facet = this.faceter; + // build a scale and have it grow the edges based on the span + let scale = pv.Scales.dateTime(facet.oldest, facet.newest); + + const Span = pv.Scales.DateTimeScale.Span; + const MS_MIN = 60 * 1000, + MS_HOUR = 60 * MS_MIN, + MS_DAY = 24 * MS_HOUR, + MS_WEEK = 7 * MS_DAY, + MS_MONTHISH = 31 * MS_DAY, + MS_YEARISH = 366 * MS_DAY; + const roughMap = {}; + roughMap[Span.DAYS] = MS_DAY; + roughMap[Span.WEEKS] = MS_WEEK; + // we overestimate since we want to slightly underestimate pixel usage + // in enoughPix's rough estimate + roughMap[Span.MONTHS] = MS_MONTHISH; + roughMap[Span.YEARS] = MS_YEARISH; + + const minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX; + + let delta = facet.newest.valueOf() - facet.oldest.valueOf(); + let span, rules, barPixBudget; + // evil side-effect land + function enoughPix(aSpan) { + span = aSpan; + // do a rough guestimate before doing something potentially expensive... + barPixBudget = Math.floor(aPixels / (delta / roughMap[span])); + if (barPixBudget < minBarPix + 1) { + return false; + } + + rules = scale.ruleValues(span); + // + 0 because we want to over-estimate slightly for niceness rounding + // reasons + barPixBudget = Math.floor(aPixels / (rules.length + 0)); + delta = scale.max().valueOf() - scale.min().valueOf(); + return barPixBudget > minBarPix; + } + + // day is our smallest unit + const ALLOWED_SPANS = [Span.DAYS, Span.WEEKS, Span.MONTHS, Span.YEARS]; + for (let trySpan of ALLOWED_SPANS) { + if (enoughPix(trySpan)) { + // do the equivalent of nice() for our chosen span + scale.min(scale.round(scale.min(), trySpan, false)); + scale.max(scale.round(scale.max(), trySpan, true)); + // try again for paranoia, but mainly for the side-effect... + if (enoughPix(trySpan)) { + break; + } + } + } + + // - Figure out our labeling strategy + // normalize the symbols into an explicit ordering + let spandex = ALLOWED_SPANS.indexOf(span); + // from least-specific to most-specific + let labelTiers = []; + // add year spans in all cases, although whether we draw bars depends on if + // we are in year mode or not + labelTiers.push({ + rules: span == Span.YEARS ? rules : scale.ruleValues(Span.YEARS, true), + // We should not hit the null member of the array... + label: [{ year: "numeric" }, { year: "2-digit" }, null], + boost: span == Span.YEARS, + noFringe: span == Span.YEARS, + }); + // add month spans if we are days or weeks... + if (spandex < 2) { + labelTiers.push({ + rules: scale.ruleValues(Span.MONTHS, true), + // try to use the full month, falling back to the short month + label: [{ month: "long" }, { month: "short" }, null], + boost: false, + }); + } + // add week spans if our granularity is days... + if (span == Span.DAYS) { + let numDays = delta / MS_DAY; + + // find out how many days we are talking about and add days if it's small + // enough, display both the date and the day of the week + if (numDays <= this._MAX_DAY_COUNT_LABEL_DISPLAY) { + labelTiers.push({ + rules, + label: [{ day: "numeric" }, null], + boost: true, + noFringe: true, + }); + labelTiers.push({ + rules, + label: [{ weekday: "short" }, null], + boost: true, + noFringe: true, + }); + } else { + // show the weeks since we're at greater than a day time-scale + labelTiers.push({ + rules: scale.ruleValues(Span.WEEKS, true), + // labeling weeks is nonsensical; no one understands ISO weeks + // numbers. + label: [null], + boost: false, + }); + } + } + + return { scale, span, rules, barPixBudget, labelTiers }; + }, + + render() { + let { scale, span, rules, barPixBudget, labelTiers } = + this.makeIdealScaleGivenSpace(this.allowedSpace); + + barPixBudget = Math.floor(barPixBudget); + + let minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX; + let maxBarPix = this._MAX_BAR_SIZE_PX + this._BAR_SPACING_PX; + + let barPix = Math.max(minBarPix, Math.min(maxBarPix, barPixBudget)); + let width = barPix * (rules.length - 1); + + let totalAxisLabelHeight = 0; + let isRTL = window.getComputedStyle(this.binding).direction == "rtl"; + + // we need to do some font-metric calculations, so create a canvas... + let fontMetricCanvas = document.createElement("canvas"); + let ctx = fontMetricCanvas.getContext("2d"); + + // do the labeling logic, + for (let labelTier of labelTiers) { + let labelRules = labelTier.rules; + let perLabelBudget = width / (labelRules.length - 1); + for (let labelFormat of labelTier.label) { + let maxWidth = 0; + let displayValues = []; + for (let iRule = 0; iRule < labelRules.length - 1; iRule++) { + // is this at the either edge of the display? in that case, it might + // be partial... + let fringe = + labelRules.length > 2 && + (iRule == 0 || iRule == labelRules.length - 2); + let labelStartDate = labelRules[iRule]; + let labelEndDate = labelRules[iRule + 1]; + let labelText = labelFormat + ? labelStartDate.toLocaleDateString(undefined, labelFormat) + : null; + let labelStartNorm = Math.max(0, scale.normalize(labelStartDate)); + let labelEndNorm = Math.min(1, scale.normalize(labelEndDate)); + let labelBudget = (labelEndNorm - labelStartNorm) * width; + if (labelText) { + let labelWidth = ctx.measureText(labelText).width; + // discard labels at the fringe who don't fit in our budget + if (fringe && !labelTier.noFringe && labelWidth > labelBudget) { + labelText = null; + } else { + maxWidth = Math.max(labelWidth, maxWidth); + } + } + + displayValues.push([ + labelStartNorm, + labelEndNorm, + labelText, + labelStartDate, + labelEndDate, + ]); + } + // there needs to be space between the labels. (we may be over-padding + // here if there is only one label with the maximum width...) + maxWidth += this._AXIS_HORIZ_MIN_SPACING_PX; + + if (labelTier.boost && maxWidth > perLabelBudget) { + // we only boost labels that are the same span as the bins, so rules + // === labelRules at this point. (and barPix === perLabelBudget) + barPix = perLabelBudget = maxWidth; + width = barPix * (labelRules.length - 1); + } + if (maxWidth <= perLabelBudget) { + labelTier.displayValues = displayValues; + labelTier.displayLabel = labelFormat != null; + labelTier.vertHeight = labelFormat + ? this._AXIS_HEIGHT_WITH_LABEL_PX + : this._AXIS_HEIGHT_NO_LABEL_PX; + labelTier.vertOffset = totalAxisLabelHeight; + totalAxisLabelHeight += + labelTier.vertHeight + this._AXIS_VERT_SPACING_PX; + + break; + } + } + } + + let barWidth = barPix - this._BAR_SPACING_PX; + + width = barPix * (rules.length - 1); + // we ideally want this to be the same size as the max rows translates to... + let height = 100; + let ch = height - totalAxisLabelHeight; + + let [bins, maxBinSize] = this.binBySpan(scale, span, rules); + + // build empty bins for our hot bins + this.emptyBins = bins.map(bin => 0); + + let binScale = maxBinSize ? ch / maxBinSize : 1; + + let vis = (this.vis = new pv.Panel() + .canvas(this.canvasNode) + // dimensions + .width(width) + .height(ch) + // margins + .bottom(totalAxisLabelHeight)); + + let faceter = this.faceter; + let dis = this; + // bin bars... + vis + .add(pv.Bar) + .data(bins) + .bottom(0) + .height(d => Math.floor(d.items.length * binScale)) + .width(() => barWidth) + .left(function () { + return isRTL ? null : this.index * barPix; + }) + .right(function () { + return isRTL ? this.index * barPix : null; + }) + .fillStyle("var(--barColor)") + .event("mouseover", function (d) { + return this.fillStyle("var(--barHlColor)"); + }) + .event("mouseout", function (d) { + return this.fillStyle("var(--barColor)"); + }) + .event("click", function (d) { + dis.constraints = [[d.startDate, d.endDate]]; + dis.binding.setAttribute("zoomedout", "false"); + FacetContext.addFacetConstraint( + faceter, + true, + dis.constraints, + true, + true + ); + }); + + this.hotBars = vis + .add(pv.Bar) + .data(this.emptyBins) + .bottom(0) + .height(d => Math.floor(d * binScale)) + .width(() => barWidth) + .left(function () { + return this.index * barPix; + }) + .fillStyle("var(--barHlColor)"); + + for (let labelTier of labelTiers) { + let labelBar = vis + .add(pv.Bar) + .data(labelTier.displayValues) + .bottom(-totalAxisLabelHeight + labelTier.vertOffset) + .height(labelTier.vertHeight) + .left(d => (isRTL ? null : Math.floor(width * d[0]))) + .right(d => (isRTL ? Math.floor(width * d[0]) : null)) + .width(d => Math.floor(width * d[1]) - Math.floor(width * d[0]) - 1) + .fillStyle("var(--dateColor)") + .event("mouseover", function (d) { + return this.fillStyle("var(--dateHLColor)"); + }) + .event("mouseout", function (d) { + return this.fillStyle("var(--dateColor)"); + }) + .event("click", function (d) { + dis.constraints = [[d[3], d[4]]]; + dis.binding.setAttribute("zoomedout", "false"); + FacetContext.addFacetConstraint( + faceter, + true, + dis.constraints, + true, + true + ); + }); + + if (labelTier.displayLabel) { + labelBar + .anchor("top") + .add(pv.Label) + .font(this._AXIS_FONT) + .textAlign("center") + .textBaseline("top") + .textStyle("var(--dateTextColor)") + .text(d => d[2]); + } + } + + vis.render(); + }, + + hoverItems(aItems) { + let itemToBin = this.itemToBin; + let bins = this.emptyBins.concat(); + for (let item of aItems) { + if (item.id in itemToBin) { + bins[itemToBin[item.id]]++; + } + } + this.hotBars.data(bins); + this.vis.render(); + }, + + clearHover() { + this.hotBars.data(this.emptyBins); + this.vis.render(); + }, + + /** + * Bin items at the given span granularity with the set of rules generated + * for the given span. This could equally as well be done as a pre-built + * array of buckets with a linear scan of items and a calculation of what + * bucket they should be placed in. + */ + binBySpan(aScale, aSpan, aRules, aItems) { + let bins = []; + let maxBinSize = 0; + let binCount = aRules.length - 1; + let itemToBin = (this.itemToBin = {}); + + // We used to break this out by case, but that was a lot of code, and it was + // somewhat ridiculous. So now we just do the simple, if somewhat more + // expensive thing. Reviewer, feel free to thank me. + // We do a pass through the rules, mapping each rounded rule to a bin. We + // then do a pass through all of the items, rounding them down and using + // that to perform a lookup against the map. We could special-case the + // rounding, but I doubt it's worth it. + let binMap = {}; + for (let iRule = 0; iRule < binCount; iRule++) { + let binStartDate = aRules[iRule], + binEndDate = aRules[iRule + 1]; + binMap[binStartDate.valueOf().toString()] = iRule; + bins.push({ items: [], startDate: binStartDate, endDate: binEndDate }); + } + let attrKey = this.attrDef.boundName; + for (let item of this.faceter.validItems) { + let val = item[attrKey]; + // round it to the rule... + val = aScale.round(val, aSpan, false); + // which we can then map... + let itemBin = binMap[val.valueOf().toString()]; + itemToBin[item.id] = itemBin; + bins[itemBin].items.push(item); + } + for (let bin of bins) { + maxBinSize = Math.max(bin.items.length, maxBinSize); + } + + return [bins, maxBinSize]; + }, +}; |