From cd4f2518914bfb7b6c6ae9e35bd2bf2dfb4b6147 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 6 Aug 2020 17:57:06 +0200 Subject: [PATCH] FEATURE: Poll breakdown 2.0 (#10345) The poll breakdown modal replaces the grouped pie charts feature. Includes: * MODAL: Untangle `onSelectPanel` Previously modal-tab component would call on click the onSelectPanel callback with itself (modal-tab) as `this` which severely limited its usefulness. Now showModal binds the callback to its controller. "The PR includes a fix/change to d-modal (b7f6ec6) that hasn't been extracted to a separate PR because it's not currently possible to test a change like this in abstract, i.e. with dynamically created controllers/components in tests. The percentage/count toggle test for the poll breakdown feature is essentially a test for that d-modal modification." --- .../discourse/app/components/modal-tab.js | 6 +- .../discourse/app/lib/show-modal.js | 5 +- .../app/mixins/modal-functionality.js | 4 - app/assets/stylesheets/common/base/modal.scss | 2 +- lib/tasks/javascript.rake | 3 + package.json | 1 + .../components/poll-breakdown-chart.js.es6 | 182 +++++++++++++++++ .../components/poll-breakdown-option.js.es6 | 61 ++++++ .../controllers/poll-breakdown.js.es6 | 83 ++++++++ .../controllers/poll-ui-builder.js.es6 | 2 +- .../components/poll-breakdown-chart.hbs | 2 + .../components/poll-breakdown-option.hbs | 17 ++ .../templates/modal/poll-breakdown.hbs | 49 +++++ .../initializers/add-poll-ui-builder.js.es6 | 2 +- .../initializers/extend-for-poll.js.es6 | 4 +- .../javascripts/widgets/discourse-poll.js.es6 | 184 +++--------------- .../stylesheets/common/poll-breakdown.scss | 116 +++++++++++ .../poll/assets/stylesheets/common/poll.scss | 27 +-- .../poll/assets/stylesheets/desktop/poll.scss | 5 +- .../poll/assets/stylesheets/mobile/poll.scss | 8 +- plugins/poll/config/locales/client.en.yml | 11 +- plugins/poll/plugin.rb | 1 + .../acceptance/poll-breakdown-test.js.es6 | 119 +++++++++++ .../acceptance/poll-pie-chart-test.js.es6 | 96 +-------- .../chartjs-plugin-datalabels.min.js | 7 + yarn.lock | 5 + 26 files changed, 708 insertions(+), 294 deletions(-) create mode 100644 plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 create mode 100644 plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 create mode 100644 plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 create mode 100644 plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-chart.hbs create mode 100644 plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-option.hbs create mode 100644 plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs create mode 100644 plugins/poll/assets/stylesheets/common/poll-breakdown.scss create mode 100644 plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 create mode 100644 public/javascripts/chartjs-plugin-datalabels.min.js diff --git a/app/assets/javascripts/discourse/app/components/modal-tab.js b/app/assets/javascripts/discourse/app/components/modal-tab.js index 3211fbbdd2..a798e22d6d 100644 --- a/app/assets/javascripts/discourse/app/components/modal-tab.js +++ b/app/assets/javascripts/discourse/app/components/modal-tab.js @@ -20,6 +20,10 @@ export default Component.extend({ }, click() { - this.onSelectPanel(this.panel); + this.set("selectedPanel", this.panel); + + if (this.onSelectPanel) { + this.onSelectPanel(this.panel); + } } }); diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js index 37c6b8efe3..6a6fb17a71 100644 --- a/app/assets/javascripts/discourse/app/lib/show-modal.js +++ b/app/assets/javascripts/discourse/app/lib/show-modal.js @@ -50,7 +50,10 @@ export default function(name, opts) { }); if (controller.actions.onSelectPanel) { - modalController.set("onSelectPanel", controller.actions.onSelectPanel); + modalController.set( + "onSelectPanel", + controller.actions.onSelectPanel.bind(controller) + ); } modalController.set( diff --git a/app/assets/javascripts/discourse/app/mixins/modal-functionality.js b/app/assets/javascripts/discourse/app/mixins/modal-functionality.js index 70b5bd010c..ac40b161b5 100644 --- a/app/assets/javascripts/discourse/app/mixins/modal-functionality.js +++ b/app/assets/javascripts/discourse/app/mixins/modal-functionality.js @@ -18,10 +18,6 @@ export default Mixin.create({ closeModal() { this.modal.send("closeModal"); this.set("panels", []); - }, - - onSelectPanel(panel) { - this.set("selectedPanel", panel); } } }); diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 178be0a01d..fc09f8990d 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -148,7 +148,7 @@ } &:not(.history-modal) { - .modal-body:not(.reorder-categories):not(.poll-ui-builder) { + .modal-body:not(.reorder-categories):not(.poll-ui-builder):not(.poll-breakdown) { max-height: 80vh !important; @media screen and (max-height: 500px) { max-height: 65vh !important; diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index c7c745242a..25392df903 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -69,6 +69,9 @@ task 'javascript:update' do }, { source: 'chart.js/dist/Chart.min.js', public: true + }, { + source: 'chartjs-plugin-datalabels/dist/chartjs-plugin-datalabels.min.js', + public: true }, { source: 'magnific-popup/dist/jquery.magnific-popup.min.js', public: true diff --git a/package.json b/package.json index 9ce74d66f8..e73fee2fbc 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "bootbox": "3.2.0", "bootstrap": "v3.4.1", "chart.js": "2.9.3", + "chartjs-plugin-datalabels": "^0.7.0", "eslint-plugin-lodash": "^6.0.0", "favcount": "https://github.com/chrishunt/favcount", "handlebars": "^4.7.0", diff --git a/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 b/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 new file mode 100644 index 0000000000..ea9a34559d --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 @@ -0,0 +1,182 @@ +import I18n from "I18n"; +import Component from "@ember/component"; +import { mapBy } from "@ember/object/computed"; +import { htmlSafe } from "@ember/template"; +import { PIE_CHART_TYPE } from "discourse/plugins/poll/controllers/poll-ui-builder"; +import { getColors } from "discourse/plugins/poll/lib/chart-colors"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Component.extend({ + // Arguments: + group: null, + options: null, + displayMode: null, + highlightedOption: null, + setHighlightedOption: null, + + classNames: "poll-breakdown-chart-container", + + _optionToSlice: null, + _previousHighlightedSliceIndex: null, + _previousDisplayMode: null, + + data: mapBy("options", "votes"), + + init() { + this._super(...arguments); + this._optionToSlice = {}; + }, + + didInsertElement() { + this._super(...arguments); + + const canvas = this.element.querySelector("canvas"); + this._chart = new window.Chart(canvas.getContext("2d"), this.chartConfig); + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (this._chart) { + this._updateDisplayMode(); + this._updateHighlight(); + } + }, + + willDestroy() { + this._super(...arguments); + + if (this._chart) { + this._chart.destroy(); + } + }, + + @discourseComputed("optionColors", "index") + colorStyle(optionColors, index) { + return htmlSafe(`background: ${optionColors[index]};`); + }, + + @discourseComputed("data", "displayMode") + chartConfig(data, displayMode) { + const transformedData = []; + let counter = 0; + + this._optionToSlice = {}; + + data.forEach((votes, index) => { + if (votes > 0) { + transformedData.push(votes); + this._optionToSlice[index] = counter++; + } + }); + + const totalVotes = transformedData.reduce((sum, votes) => sum + votes, 0); + const colors = getColors(data.length).filter( + (color, index) => data[index] > 0 + ); + + return { + type: PIE_CHART_TYPE, + plugins: [window.ChartDataLabels], + data: { + datasets: [ + { + data: transformedData, + backgroundColor: colors, + // TODO: It's a workaround for Chart.js' terrible hover styling. + // It will break on non-white backgrounds. + // Should be updated after #10341 lands + hoverBorderColor: "#fff" + } + ] + }, + options: { + plugins: { + datalabels: { + color: "#333", + backgroundColor: "rgba(255, 255, 255, 0.5)", + borderRadius: 2, + font: { + family: getComputedStyle(document.body).fontFamily, + size: 16 + }, + padding: { + top: 2, + right: 6, + bottom: 2, + left: 6 + }, + formatter(votes) { + if (displayMode !== "percentage") { + return votes; + } + + const percent = I18n.toNumber((votes / totalVotes) * 100.0, { + precision: 1 + }); + + return `${percent}%`; + } + } + }, + responsive: true, + aspectRatio: 1.1, + animation: { duration: 0 }, + tooltips: false, + onHover: (event, activeElements) => { + if (!activeElements.length) { + this.setHighlightedOption(null); + return; + } + + const sliceIndex = activeElements[0]._index; + const optionIndex = Object.keys(this._optionToSlice).find( + option => this._optionToSlice[option] === sliceIndex + ); + + // Clear the array to avoid issues in Chart.js + activeElements.length = 0; + + this.setHighlightedOption(Number(optionIndex)); + } + } + }; + }, + + _updateDisplayMode() { + if (this.displayMode !== this._previousDisplayMode) { + const config = this.chartConfig; + this._chart.data.datasets = config.data.datasets; + this._chart.options = config.options; + + this._chart.update(); + this._previousDisplayMode = this.displayMode; + } + }, + + _updateHighlight() { + const meta = this._chart.getDatasetMeta(0); + + if (this._previousHighlightedSliceIndex !== null) { + const slice = meta.data[this._previousHighlightedSliceIndex]; + meta.controller.removeHoverStyle(slice); + this._chart.draw(); + } + + if (this.highlightedOption === null) { + this._previousHighlightedSliceIndex = null; + return; + } + + const sliceIndex = this._optionToSlice[this.highlightedOption]; + if (typeof sliceIndex === "undefined") { + this._previousHighlightedSliceIndex = null; + return; + } + + const slice = meta.data[sliceIndex]; + this._previousHighlightedSliceIndex = sliceIndex; + meta.controller.setHoverStyle(slice); + this._chart.draw(); + } +}); diff --git a/plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 b/plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 new file mode 100644 index 0000000000..f1816b13f9 --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 @@ -0,0 +1,61 @@ +import I18n from "I18n"; +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import { htmlSafe } from "@ember/template"; +import { propertyEqual } from "discourse/lib/computed"; +import discourseComputed from "discourse-common/utils/decorators"; +import { getColors } from "discourse/plugins/poll/lib/chart-colors"; + +export default Component.extend({ + // Arguments: + option: null, + index: null, + totalVotes: null, + optionsCount: null, + displayMode: null, + highlightedOption: null, + onMouseOver: null, + onMouseOut: null, + + tagName: "", + + highlighted: propertyEqual("highlightedOption", "index"), + showPercentage: equal("displayMode", "percentage"), + + @discourseComputed("option.votes", "totalVotes") + percent(votes, total) { + return I18n.toNumber((votes / total) * 100.0, { precision: 1 }); + }, + + @discourseComputed("optionsCount") + optionColors(optionsCount) { + return getColors(optionsCount); + }, + + @discourseComputed("highlighted") + colorBackgroundStyle(highlighted) { + if (highlighted) { + // TODO: Use CSS variables (#10341) + return htmlSafe("background: rgba(0, 0, 0, 0.1);"); + } + }, + + @discourseComputed("highlighted", "optionColors", "index") + colorPreviewStyle(highlighted, optionColors, index) { + const color = highlighted + ? window.Chart.helpers.getHoverColor(optionColors[index]) + : optionColors[index]; + + return htmlSafe(`background: ${color};`); + }, + + @action + onHover(active) { + if (active) { + this.onMouseOver(); + } else { + this.onMouseOut(); + } + } +}); diff --git a/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 new file mode 100644 index 0000000000..f5cb61bcba --- /dev/null +++ b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 @@ -0,0 +1,83 @@ +import I18n from "I18n"; +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { classify } from "@ember/string"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import loadScript from "discourse/lib/load-script"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Controller.extend(ModalFunctionality, { + model: null, + charts: null, + groupedBy: null, + highlightedOption: null, + displayMode: "percentage", + + @discourseComputed("model.groupableUserFields") + groupableUserFields(fields) { + return fields.map(field => { + const transformed = field.split("_").filter(Boolean); + + if (transformed.length > 1) { + transformed[0] = classify(transformed[0]); + } + + return { id: field, label: transformed.join(" ") }; + }); + }, + + @discourseComputed("model.poll.options") + totalVotes(options) { + return options.reduce((sum, option) => sum + option.votes, 0); + }, + + onShow() { + this.set("charts", null); + this.set("displayMode", "percentage"); + this.set("groupedBy", this.model.groupableUserFields[0]); + + loadScript("/javascripts/Chart.min.js") + .then(() => loadScript("/javascripts/chartjs-plugin-datalabels.min.js")) + .then(() => { + window.Chart.plugins.unregister(window.ChartDataLabels); + this.fetchGroupedPollData(); + }); + }, + + fetchGroupedPollData() { + return ajax("/polls/grouped_poll_results.json", { + data: { + post_id: this.model.post.id, + poll_name: this.model.poll.name, + user_field_name: this.groupedBy + } + }) + .catch(error => { + if (error) { + popupAjaxError(error); + } else { + bootbox.alert(I18n.t("poll.error_while_fetching_voters")); + } + }) + .then(result => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("charts", result.grouped_results); + }); + }, + + @action + setGrouping(value) { + this.set("groupedBy", value); + this.fetchGroupedPollData(); + }, + + @action + onSelectPanel(panel) { + this.set("displayMode", panel.id); + } +}); diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 index 0dc7325150..2c0c4ff36e 100644 --- a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -1,7 +1,7 @@ import I18n from "I18n"; import Controller from "@ember/controller"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; export const BAR_CHART_TYPE = "bar"; export const PIE_CHART_TYPE = "pie"; diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-chart.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-chart.hbs new file mode 100644 index 0000000000..9e91bc41e7 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-chart.hbs @@ -0,0 +1,2 @@ + + diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-option.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-option.hbs new file mode 100644 index 0000000000..b56106982e --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-breakdown-option.hbs @@ -0,0 +1,17 @@ +
  • + + + + {{#if showPercentage}} + {{this.percent}}% + {{else}} + {{@option.votes}} + {{/if}} + + {{{@option.html}}} +
  • diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs new file mode 100644 index 0000000000..0cb26c08d5 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs @@ -0,0 +1,49 @@ +{{#d-modal-body title="poll.breakdown.title"}} +
    + {{!-- TODO: replace with the (optional) poll title --}} +

    {{this.model.post.topic.title}}

    + +
    {{i18n "poll.breakdown.votes" count=this.model.poll.voters}}
    + + +
    + +
    +
    + + + {{combo-box + content=this.groupableUserFields + value=this.groupedBy + nameProperty="label" + class="poll-breakdown-dropdown" + onChange=(action this.setGrouping) + }} +
    + +
    + {{#each this.charts as |chart|}} + {{poll-breakdown-chart + group=(get chart "group") + options=(get chart "options") + displayMode=this.displayMode + highlightedOption=this.highlightedOption + setHighlightedOption=(fn (mut this.highlightedOption)) + }} + {{/each}} +
    +
    +{{/d-modal-body}} diff --git a/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 index d9da92d252..eae72fff0b 100644 --- a/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 @@ -1,6 +1,6 @@ import { withPluginApi } from "discourse/lib/plugin-api"; -import discourseComputed from "discourse-common/utils/decorators"; import showModal from "discourse/lib/show-modal"; +import discourseComputed from "discourse-common/utils/decorators"; function initializePollUIBuilder(api) { api.modifyClass("controller:composer", { diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index 13b209dd50..897d37eee5 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -1,8 +1,8 @@ import EmberObject from "@ember/object"; import { withPluginApi } from "discourse/lib/plugin-api"; -import { observes } from "discourse-common/utils/decorators"; -import { getRegister } from "discourse-common/lib/get-owner"; import WidgetGlue from "discourse/widgets/glue"; +import { getRegister } from "discourse-common/lib/get-owner"; +import { observes } from "discourse-common/utils/decorators"; function initializePolls(api) { const register = getRegister(api); diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 index 96f6f32003..acff44d246 100644 --- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 +++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 @@ -1,18 +1,18 @@ import I18n from "I18n"; -import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; -import { iconNode } from "discourse-common/lib/icon-library"; -import RawHtml from "discourse/widgets/raw-html"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import evenRound from "discourse/plugins/poll/lib/even-round"; -import { avatarFor } from "discourse/widgets/post"; -import round from "discourse/lib/round"; import { relativeAge } from "discourse/lib/formatter"; import loadScript from "discourse/lib/load-script"; -import { getColors } from "../lib/chart-colors"; -import { classify } from "@ember/string"; -import { PIE_CHART_TYPE } from "../controllers/poll-ui-builder"; +import round from "discourse/lib/round"; +import showModal from "discourse/lib/show-modal"; +import { avatarFor } from "discourse/widgets/post"; +import RawHtml from "discourse/widgets/raw-html"; +import { createWidget } from "discourse/widgets/widget"; +import { iconNode } from "discourse-common/lib/icon-library"; +import { PIE_CHART_TYPE } from "discourse/plugins/poll/controllers/poll-ui-builder"; +import { getColors } from "discourse/plugins/poll/lib/chart-colors"; +import evenRound from "discourse/plugins/poll/lib/even-round"; function optionHtml(option) { const $node = $(`${option.html}`); @@ -453,120 +453,6 @@ createWidget("discourse-poll-info", { } }); -function transformUserFieldToLabel(fieldName) { - let transformed = fieldName.split("_").filter(Boolean); - if (transformed.length > 1) { - transformed[0] = classify(transformed[0]); - } - return transformed.join(" "); -} - -createWidget("discourse-poll-grouped-pies", { - tagName: "div.poll-grouped-pies", - buildAttributes(attrs) { - return { - id: `poll-results-grouped-pie-charts-${attrs.id}` - }; - }, - - html(attrs) { - const fields = Object.assign({}, attrs.groupableUserFields); - const fieldSelectId = `field-select-${attrs.id}`; - attrs.groupedBy = attrs.groupedBy || fields[0]; - - let contents = []; - - const btn = this.attach("button", { - className: "btn-default poll-group-by-toggle", - label: "poll.ungroup-results.label", - title: "poll.ungroup-results.title", - icon: "far-eye-slash", - action: "toggleGroupedPieCharts" - }); - const select = h( - `select#${fieldSelectId}.poll-group-by-selector`, - { value: attrs.groupBy }, - attrs.groupableUserFields.map(field => { - return h("option", { value: field }, transformUserFieldToLabel(field)); - }) - ); - contents.push(h("div.poll-grouped-pies-controls", [btn, select])); - - ajax("/polls/grouped_poll_results.json", { - data: { - post_id: attrs.post.id, - poll_name: attrs.poll.name, - user_field_name: attrs.groupedBy - } - }) - .catch(error => { - if (error) { - popupAjaxError(error); - } else { - bootbox.alert(I18n.t("poll.error_while_fetching_voters")); - } - }) - .then(result => { - let groupBySelect = document.getElementById(fieldSelectId); - if (!groupBySelect) return; - - groupBySelect.value = attrs.groupedBy; - const parent = document.getElementById( - `poll-results-grouped-pie-charts-${attrs.id}` - ); - - for ( - let chartIdx = 0; - chartIdx < result.grouped_results.length; - chartIdx++ - ) { - const data = result.grouped_results[chartIdx].options.mapBy("votes"); - const labels = result.grouped_results[chartIdx].options.mapBy("html"); - const chartConfig = pieChartConfig(data, labels, { - aspectRatio: 1.2 - }); - const canvasId = `pie-${attrs.id}-${chartIdx}`; - let el = document.querySelector(`#${canvasId}`); - if (!el) { - const container = document.createElement("div"); - container.classList.add("poll-grouped-pie-container"); - - const label = document.createElement("label"); - label.classList.add("poll-pie-label"); - label.textContent = result.grouped_results[chartIdx].group; - - const canvas = document.createElement("canvas"); - canvas.classList.add(`poll-grouped-pie-${attrs.id}`); - canvas.id = canvasId; - - container.appendChild(label); - container.appendChild(canvas); - parent.appendChild(container); - // eslint-disable-next-line - new Chart(canvas.getContext("2d"), chartConfig); - } else { - // eslint-disable-next-line - Chart.helpers.each(Chart.instances, function(instance) { - if (instance.chart.canvas.id === canvasId && el.$chartjs) { - instance.destroy(); - // eslint-disable-next-line - new Chart(el.getContext("2d"), chartConfig); - } - }); - } - } - }); - return contents; - }, - - click(e) { - let select = $(e.target).closest("select"); - if (select.length) { - this.sendWidgetAction("refreshCharts", select[0].value); - } - } -}); - function clearPieChart(id) { let el = document.querySelector(`#poll-results-chart-${id}`); el && el.parentNode.removeChild(el); @@ -607,27 +493,23 @@ createWidget("discourse-poll-pie-chart", { return contents; } - let btn; - let chart; - if (attrs.groupResults && attrs.groupableUserFields.length > 0) { - chart = this.attach("discourse-poll-grouped-pies", attrs); - clearPieChart(attrs.id); - } else { - if (attrs.groupableUserFields.length) { - btn = this.attach("button", { - className: "btn-default poll-group-by-toggle", - label: "poll.group-results.label", - title: "poll.group-results.title", - icon: "far-eye", - action: "toggleGroupedPieCharts" - }); - } + if (attrs.groupableUserFields.length) { + const button = this.attach("button", { + className: "btn-default poll-show-breakdown", + label: "poll.group-results.label", + title: "poll.group-results.title", + icon: "far-eye", + action: "showBreakdown" + }); - chart = this.attach("discourse-poll-pie-canvas", attrs); + contents.push(button); } - contents.push(btn); + + const chart = this.attach("discourse-poll-pie-canvas", attrs); contents.push(chart); + contents.push(h(`div#poll-results-legend-${attrs.id}.pie-chart-legends`)); + return contents; } }); @@ -1072,19 +954,13 @@ export default createWidget("discourse-poll", { }); }, - toggleGroupedPieCharts() { - this.attrs.groupResults = !this.attrs.groupResults; - }, - - refreshCharts(newGroupedByValue) { - let el = document.getElementById( - `poll-results-grouped-pie-charts-${this.attrs.id}` - ); - Array.from(el.getElementsByClassName("poll-grouped-pie-container")).forEach( - container => { - el.removeChild(container); - } - ); - this.attrs.groupedBy = newGroupedByValue; + showBreakdown() { + showModal("poll-breakdown", { + model: this.attrs, + panels: [ + { id: "percentage", title: "poll.breakdown.percentage" }, + { id: "count", title: "poll.breakdown.count" } + ] + }); } }); diff --git a/plugins/poll/assets/stylesheets/common/poll-breakdown.scss b/plugins/poll/assets/stylesheets/common/poll-breakdown.scss new file mode 100644 index 0000000000..c178c7bd38 --- /dev/null +++ b/plugins/poll/assets/stylesheets/common/poll-breakdown.scss @@ -0,0 +1,116 @@ +.poll-breakdown-modal { + .modal-inner-container { + max-width: unset; + width: 90vw; + } + + .modal-tabs { + justify-content: flex-end; + } + + .modal-body { + display: grid; + height: 80vh; + grid: auto-flow / 1fr 2fr; + padding: 0; + } +} + +.poll-breakdown-sidebar { + background: var(--primary-very-low); + box-sizing: border-box; + padding: 1rem; +} + +.poll-breakdown-title { + font-weight: 600; + margin: 0; + text-align: center; +} + +.poll-breakdown-total-votes { + font-size: 1.2rem; + font-weight: 600; + margin-top: 0.5rem; + text-align: center; +} + +.poll-breakdown-options { + display: grid; + list-style: none; + margin: 1.5rem 0 0 0; +} + +.poll-breakdown-option { + align-items: center; + border-radius: 5px; + column-gap: 0.66rem; + cursor: default; + display: grid; + grid-template-columns: 2.5rem 1fr; + row-gap: 0.1rem; + justify-content: start; + padding: 0.5rem 1rem; +} + +.poll-breakdown-option-color { + align-self: end; + border: 1px solid rgba(0, 0, 0, 0.15); + grid-column: 1; + height: 0.6rem; + justify-self: center; + width: 1.2rem; +} + +.poll-breakdown-option-count { + align-self: start; + font-size: 0.9rem; + grid-column: 1; + justify-self: center; +} + +.poll-breakdown-option-text { + grid-column: 2; + grid-row: 1/3; +} + +.poll-breakdown-body { + box-sizing: border-box; + padding: 1rem 2rem; +} + +.poll-breakdown-body-header { + align-items: center; + border-bottom: 1px solid var(--primary-low); + display: flex; + flex: 0 0 auto; + padding-bottom: 0.5rem; +} + +.poll-breakdown-body-header-label { + font-size: 1.2rem; + font-weight: 600; + margin: 0; +} + +.poll-breakdown-dropdown { + margin-left: 1rem; +} + +.poll-breakdown-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(33.3%, 0.33fr)); +} + +.poll-breakdown-chart-container { + display: flex; + flex-direction: column; + justify-content: flex-end; + margin-top: 1rem; + position: relative; +} + +.poll-breakdown-chart-label { + display: block; + text-align: center; +} diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss index e99f0c4e2f..a40bb78917 100644 --- a/plugins/poll/assets/stylesheets/common/poll.scss +++ b/plugins/poll/assets/stylesheets/common/poll.scss @@ -149,41 +149,22 @@ div.poll { } .poll-results-chart { - height: 310px; + height: 320px; overflow-y: auto; overflow-x: hidden; } - .poll-group-by-toggle { + .poll-show-breakdown { margin-bottom: 10px; } - - .poll-group-by-selector { - height: 30px; - } - - .poll-grouped-pie-container { - display: inline-block; - position: relative; - padding: 15px 0; - - .poll-pie-label { - display: block; - text-align: center; - } - } } div.poll.pie { .poll-container { display: inline-block; - height: 310px; - max-height: 310px; + height: 320px; + max-height: 320px; overflow-y: auto; - - .poll-grouped-pie-container { - width: 50%; - } } .poll-info { display: inline-block; diff --git a/plugins/poll/assets/stylesheets/desktop/poll.scss b/plugins/poll/assets/stylesheets/desktop/poll.scss index 436dc952bf..bbfffda269 100644 --- a/plugins/poll/assets/stylesheets/desktop/poll.scss +++ b/plugins/poll/assets/stylesheets/desktop/poll.scss @@ -49,11 +49,8 @@ div.poll { div.poll.pie { .poll-container { width: calc(100% - 190px); - - .poll-grouped-pie-container { - width: 50%; - } } + .poll-info { display: inline-block; width: 150px; diff --git a/plugins/poll/assets/stylesheets/mobile/poll.scss b/plugins/poll/assets/stylesheets/mobile/poll.scss index 596c036d8e..037dfb7584 100644 --- a/plugins/poll/assets/stylesheets/mobile/poll.scss +++ b/plugins/poll/assets/stylesheets/mobile/poll.scss @@ -28,15 +28,15 @@ div.poll { margin-left: 0.25em; } } + + .poll-show-breakdown { + display: none; + } } div.poll.pie { .poll-container { width: calc(100% - 30px); border-bottom: 1px solid var(--primary-low); - - .poll-grouped-pie-container { - width: 100%; - } } } diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml index 03a736e69d..856009b504 100644 --- a/plugins/poll/config/locales/client.en.yml +++ b/plugins/poll/config/locales/client.en.yml @@ -52,10 +52,6 @@ en: title: "Group votes by user field" label: "Show breakdown" - ungroup-results: - title: "Combine all votes" - label: "Hide breakdown" - export-results: title: "Export the poll results" label: "Export" @@ -74,6 +70,13 @@ en: closes_in: "Closes in %{timeLeft}." age: "Closed %{age}" + breakdown: + title: "Poll results" + votes: "%{count} votes" + breakdown: "Breakdown" + percentage: "Percentage" + count: "Count" + error_while_toggling_status: "Sorry, there was an error toggling the status of this poll." error_while_casting_votes: "Sorry, there was an error casting your votes." error_while_fetching_voters: "Sorry, there was an error displaying the voters." diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 8331b8fca6..1dee522602 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -8,6 +8,7 @@ register_asset "stylesheets/common/poll.scss" register_asset "stylesheets/common/poll-ui-builder.scss" +register_asset "stylesheets/common/poll-breakdown.scss" register_asset "stylesheets/desktop/poll.scss", :desktop register_asset "stylesheets/mobile/poll.scss", :mobile register_asset "stylesheets/mobile/poll-ui-builder.scss", :mobile diff --git a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 new file mode 100644 index 0000000000..4b76ad90c7 --- /dev/null +++ b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 @@ -0,0 +1,119 @@ +import { acceptance } from "helpers/qunit-helpers"; +import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { Promise } from "rsvp"; + +acceptance("Poll breakdown", { + loggedIn: true, + settings: { poll_enabled: true, poll_groupable_user_fields: "something" }, + beforeEach() { + clearPopupMenuOptionsCallback(); + }, + pretend(server, helper) { + server.get("/polls/grouped_poll_results.json", () => { + return new Promise(resolve => { + resolve( + helper.response({ + grouped_results: [ + { + group: "Engineering", + options: [ + { + digest: "687a1ccf3c6a260f9aeeb7f68a1d463c", + html: "This Is", + votes: 1 + }, + { + digest: "9377906763a1221d31d656ea0c4a4495", + html: "A test for sure", + votes: 1 + }, + { + digest: "ecf47c65a85a0bb20029072b1b721977", + html: "Why not give it some more", + votes: 1 + } + ] + }, + { + group: "Marketing", + options: [ + { + digest: "687a1ccf3c6a260f9aeeb7f68a1d463c", + html: "This Is", + votes: 1 + }, + { + digest: "9377906763a1221d31d656ea0c4a4495", + html: "A test for sure", + votes: 1 + }, + { + digest: "ecf47c65a85a0bb20029072b1b721977", + html: "Why not give it some more", + votes: 1 + } + ] + } + ] + }) + ); + }); + }); + } +}); + +test("Displaying the poll breakdown modal", async assert => { + await visit("/t/-/topic_with_pie_chart_poll"); + + assert.equal( + find(".poll-show-breakdown").text(), + "Show breakdown", + "shows the breakdown button when poll_groupable_user_fields is non-empty" + ); + + await click(".poll-show-breakdown:first"); + + assert.equal( + find(".poll-breakdown-total-votes")[0].textContent.trim(), + "2 votes", + "display the correct total vote count" + ); + + assert.equal( + find(".poll-breakdown-chart-container").length, + 2, + "renders a chart for each of the groups in group_results response" + ); + + assert.ok( + find(".poll-breakdown-chart-container > canvas")[0].$chartjs, + "$chartjs is defined on the pie charts" + ); +}); + +test("Changing the display mode from percentage to count", async assert => { + await visit("/t/-/topic_with_pie_chart_poll"); + await click(".poll-show-breakdown:first"); + + assert.equal( + find(".poll-breakdown-option-count:first")[0].textContent.trim(), + "40.0%", + "displays the correct vote percentage" + ); + + await click(".modal-tabs .count"); + + assert.equal( + find(".poll-breakdown-option-count:first")[0].textContent.trim(), + "2", + "displays the correct vote count" + ); + + await click(".modal-tabs .percentage"); + + assert.equal( + find(".poll-breakdown-option-count:last")[0].textContent.trim(), + "20.0%", + "displays the percentage again" + ); +}); diff --git a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 index 379a9554ad..02696d2035 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 @@ -1,68 +1,11 @@ import { acceptance } from "helpers/qunit-helpers"; -import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; -import { Promise } from "rsvp"; acceptance("Rendering polls with pie charts - desktop", { loggedIn: true, - settings: { poll_enabled: true, poll_groupable_user_fields: "something" }, - beforeEach() { - clearPopupMenuOptionsCallback(); - }, - pretend(server, helper) { - server.get("/polls/grouped_poll_results.json", () => { - return new Promise(resolve => { - resolve( - helper.response({ - grouped_results: [ - { - group: "Engineering", - options: [ - { - digest: "687a1ccf3c6a260f9aeeb7f68a1d463c", - html: "This Is", - votes: 1 - }, - { - digest: "9377906763a1221d31d656ea0c4a4495", - html: "A test for sure", - votes: 1 - }, - { - digest: "ecf47c65a85a0bb20029072b1b721977", - html: "Why not give it some more", - votes: 1 - } - ] - }, - { - group: "Marketing", - options: [ - { - digest: "687a1ccf3c6a260f9aeeb7f68a1d463c", - html: "This Is", - votes: 1 - }, - { - digest: "9377906763a1221d31d656ea0c4a4495", - html: "A test for sure", - votes: 1 - }, - { - digest: "ecf47c65a85a0bb20029072b1b721977", - html: "Why not give it some more", - votes: 1 - } - ] - } - ] - }) - ); - }); - }); - } + settings: { poll_enabled: true, poll_groupable_user_fields: "something" } }); -test("Polls", async assert => { +test("Displays the pie chart", async assert => { await visit("/t/-/topic_with_pie_chart_poll"); const poll = find(".poll")[0]; @@ -90,39 +33,4 @@ test("Polls", async assert => { 1, "Renders the chart div instead of bar container" ); - - assert.equal( - find(".poll-group-by-toggle").text(), - "Show breakdown", - "Shows the breakdown button when poll_groupable_user_fields is non-empty" - ); - - await click(".poll-group-by-toggle:first"); - - assert.equal( - find(".poll-group-by-toggle").text(), - "Hide breakdown", - "Shows the combine breakdown button after toggle is clicked" - ); - - // Double click to make sure the state toggles back to combined view - await click(".toggle-results:first"); - await click(".toggle-results:first"); - - assert.equal( - find(".poll-group-by-toggle").text(), - "Hide breakdown", - "Returns to the grouped view, after toggling results shown" - ); - - assert.equal( - find(".poll-grouped-pie-container").length, - 2, - "Renders a chart for each of the groups in group_results response" - ); - - assert.ok( - find(".poll-grouped-pie-container > canvas")[0].$chartjs, - "$chartjs is defined on the pie charts" - ); }); diff --git a/public/javascripts/chartjs-plugin-datalabels.min.js b/public/javascripts/chartjs-plugin-datalabels.min.js new file mode 100644 index 0000000000..75eb420336 --- /dev/null +++ b/public/javascripts/chartjs-plugin-datalabels.min.js @@ -0,0 +1,7 @@ +/*! + * chartjs-plugin-datalabels v0.7.0 + * https://chartjs-plugin-datalabels.netlify.com + * (c) 2019 Chart.js Contributors + * Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js"],e):(t=t||self).ChartDataLabels=e(t.Chart)}(this,function(t){"use strict";var e=(t=t&&t.hasOwnProperty("default")?t.default:t).helpers,r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),n={toTextLines:function(t){var r,n=[];for(t=[].concat(t);t.length;)"string"==typeof(r=t.pop())?n.unshift.apply(n,r.split("\n")):Array.isArray(r)?t.push.apply(t,r):e.isNullOrUndef(t)||n.unshift(""+r);return n},toFontString:function(t){return!t||e.isNullOrUndef(t.size)||e.isNullOrUndef(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family},textSize:function(t,e,r){var n,i=[].concat(e),o=i.length,a=t.font,l=0;for(t.font=r.string,n=0;nr.right&&(n|=l),er.bottom&&(n|=s),n}function d(t,e){var r,n,i=e.anchor,o=t;return e.clamp&&(o=function(t,e){for(var r,n,i,o=t.x0,d=t.y0,c=t.x1,h=t.y1,x=f(o,d,e),y=f(c,h,e);x|y&&!(x&y);)(r=x||y)&u?(n=o+(c-o)*(e.top-d)/(h-d),i=e.top):r&s?(n=o+(c-o)*(e.bottom-d)/(h-d),i=e.bottom):r&l?(i=d+(h-d)*(e.right-o)/(c-o),n=e.right):r&a&&(i=d+(h-d)*(e.left-o)/(c-o),n=e.left),r===x?x=f(o=n,d=i,e):y=f(c=n,h=i,e);return{x0:o,x1:c,y0:d,y1:h}}(o,e.area)),"start"===i?(r=o.x0,n=o.y0):"end"===i?(r=o.x1,n=o.y1):(r=(o.x0+o.x1)/2,n=(o.y0+o.y1)/2),function(t,e,r,n,i){switch(i){case"center":r=n=0;break;case"bottom":r=0,n=1;break;case"right":r=1,n=0;break;case"left":r=-1,n=0;break;case"top":r=0,n=-1;break;case"start":r=-r,n=-n;break;case"end":break;default:i*=Math.PI/180,r=Math.cos(i),n=Math.sin(i)}return{x:t,y:e,vx:r,vy:n}}(r,n,t.vx,t.vy,e.align)}var c={arc:function(t,e){var r=(t.startAngle+t.endAngle)/2,n=Math.cos(r),i=Math.sin(r),o=t.innerRadius,a=t.outerRadius;return d({x0:t.x+n*o,y0:t.y+i*o,x1:t.x+n*a,y1:t.y+i*a,vx:n,vy:i},e)},point:function(t,e){var r=i(t,e.origin),n=r.x*t.radius,o=r.y*t.radius;return d({x0:t.x-n,y0:t.y-o,x1:t.x+n,y1:t.y+o,vx:r.x,vy:r.y},e)},rect:function(t,e){var r=i(t,e.origin),n=t.x,o=t.y,a=0,l=0;return t.horizontal?(n=Math.min(t.x,t.base),a=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),l=Math.abs(t.base-t.y)),d({x0:n,y0:o+l,x1:n+a,y1:o,vx:r.x,vy:r.y},e)},fallback:function(t,e){var r=i(t,e.origin);return d({x0:t.x,y0:t.y,x1:t.x,y1:t.y,vx:r.x,vy:r.y},e)}},h=t.helpers,x=n.rasterize;function y(t){var e=t._model.horizontal,r=t._scale||e&&t._xScale||t._yScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var n=r.getBasePixel();return e?{x:n,y:null}:{x:null,y:n}}function v(t,e,r){var n=t.shadowBlur,i=r.stroked,o=x(r.x),a=x(r.y),l=x(r.w);i&&t.strokeText(e,o,a,l),r.filled&&(n&&i&&(t.shadowBlur=0),t.fillText(e,o,a,l),n&&i&&(t.shadowBlur=n))}var _=function(t,e,r,n){var i=this;i._config=t,i._index=n,i._model=null,i._rects=null,i._ctx=e,i._el=r};h.extend(_.prototype,{_modelize:function(e,r,i,o){var a,l=this._index,s=h.options.resolve,u=n.parseFont(s([i.font,{}],o,l)),f=s([i.color,t.defaults.global.defaultFontColor],o,l);return{align:s([i.align,"center"],o,l),anchor:s([i.anchor,"center"],o,l),area:o.chart.chartArea,backgroundColor:s([i.backgroundColor,null],o,l),borderColor:s([i.borderColor,null],o,l),borderRadius:s([i.borderRadius,0],o,l),borderWidth:s([i.borderWidth,0],o,l),clamp:s([i.clamp,!1],o,l),clip:s([i.clip,!1],o,l),color:f,display:e,font:u,lines:r,offset:s([i.offset,0],o,l),opacity:s([i.opacity,1],o,l),origin:y(this._el),padding:h.options.toPadding(s([i.padding,0],o,l)),positioner:(a=this._el,a instanceof t.elements.Arc?c.arc:a instanceof t.elements.Point?c.point:a instanceof t.elements.Rectangle?c.rect:c.fallback),rotation:s([i.rotation,0],o,l)*(Math.PI/180),size:n.textSize(this._ctx,r,u),textAlign:s([i.textAlign,"start"],o,l),textShadowBlur:s([i.textShadowBlur,0],o,l),textShadowColor:s([i.textShadowColor,f],o,l),textStrokeColor:s([i.textStrokeColor,f],o,l),textStrokeWidth:s([i.textStrokeWidth,0],o,l)}},update:function(t){var e,r,i,o=this,a=null,l=null,s=o._index,u=o._config,f=h.options.resolve([u.display,!0],t,s);f&&(e=t.dataset.data[s],r=h.valueOrDefault(h.callback(u.formatter,[e,t]),e),(i=h.isNullOrUndef(r)?[]:n.toTextLines(r)).length&&(l=function(t){var e=t.borderWidth||0,r=t.padding,n=t.size.height,i=t.size.width,o=-i/2,a=-n/2;return{frame:{x:o-r.left-e,y:a-r.top-e,w:i+r.width+2*e,h:n+r.height+2*e},text:{x:o,y:a,w:i,h:n}}}(a=o._modelize(f,i,u,t)))),o._model=a,o._rects=l},geometry:function(){return this._rects?this._rects.frame:{}},rotation:function(){return this._model?this._model.rotation:0},visible:function(){return this._model&&this._model.opacity},model:function(){return this._model},draw:function(t,e){var r,i=t.ctx,o=this._model,a=this._rects;this.visible()&&(i.save(),o.clip&&(r=o.area,i.beginPath(),i.rect(r.left,r.top,r.right-r.left,r.bottom-r.top),i.clip()),i.globalAlpha=n.bound(0,o.opacity,1),i.translate(x(e.x),x(e.y)),i.rotate(o.rotation),function(t,e,r){var n=r.backgroundColor,i=r.borderColor,o=r.borderWidth;(n||i&&o)&&(t.beginPath(),h.canvas.roundedRect(t,x(e.x)+o/2,x(e.y)+o/2,x(e.w)-o,x(e.h)-o,r.borderRadius),t.closePath(),n&&(t.fillStyle=n,t.fill()),i&&o&&(t.strokeStyle=i,t.lineWidth=o,t.lineJoin="miter",t.stroke()))}(i,a.frame,o),function(t,e,r,n){var i,o=n.textAlign,a=n.color,l=!!a,s=n.font,u=e.length,f=n.textStrokeColor,d=n.textStrokeWidth,c=f&&d;if(u&&(l||c))for(r=function(t,e,r){var n=r.lineHeight,i=t.w,o=t.x;return"center"===e?o+=i/2:"end"!==e&&"right"!==e||(o+=i),{h:n,w:i,x:o,y:t.y+n/2}}(r,o,s),t.font=s.string,t.textAlign=o,t.textBaseline="middle",t.shadowBlur=n.textShadowBlur,t.shadowColor=n.textShadowColor,l&&(t.fillStyle=a),c&&(t.lineJoin="round",t.lineWidth=d,t.strokeStyle=f),i=0,u=e.length;ie.x+e.w+2||t.y>e.y+e.h+2)},intersects:function(t){var e,r,n,i=this._points(),o=t._points(),a=[k(i[0],i[1]),k(i[0],i[3])];for(this._rotation!==t._rotation&&a.push(k(o[0],o[1]),k(o[0],o[3])),e=0;e=0;--r)for(i=t[r].$layout,n=r-1;n>=0&&i._visible;--n)(o=t[n].$layout)._visible&&i._box.intersects(o._box)&&e(i,o)})(t,function(t,e){var r=t._hidable,n=e._hidable;r&&n||n?e._visible=!1:r&&(t._visible=!1)})}(t)},lookup:function(t,e){var r,n;for(r=t.length-1;r>=0;--r)if((n=t[r].$layout)&&n._visible&&n._box.contains(e))return t[r];return null},draw:function(t,e){var r,n,i,o,a,l;for(r=0,n=e.length;r