UNPKG

survey-analytics

Version:

SurveyJS Dashboard is a UI component for visualizing and analyzing survey data. It interprets the form JSON schema to identify question types and renders collected responses using interactive charts and tables.

762 lines (755 loc) 30.4 kB
/*! * surveyjs - SurveyJS Dashboard library v2.5.2 * Copyright (c) 2015-2025 Devsoft Baltic OÜ - http://surveyjs.io/ * License: MIT (http://www.opensource.org/licenses/mit-license.php) */ import { Event, ItemValue } from 'survey-core'; import { l as localization, b as DataHelper } from './shared.mjs'; export { D as DocumentHelper, a as setupLocale, s as surveyStrings } from './shared.mjs'; import { N as NumberModel, b as VisualizationManager, S as SelectBase, u as defaultStatisticsCalculator, B as BooleanModel, H as HistogramModel, M as Matrix, s as PivotModel, R as RankingModel, a as VisualizerBase, _ as __awaiter } from './shared2.mjs'; export { A as AlternativeVisualizersWrapper, D as DataProvider, q as NpsAdapter, r as NpsVisualizer, p as NpsVisualizerWidget, P as PostponeHelper, n as StatisticsTable, m as StatisticsTableAdapter, o as StatisticsTableBoolean, l as Text, T as TextTableAdapter, j as VisualizationComposite, f as VisualizationMatrixDropdown, e as VisualizationMatrixDynamic, c as VisualizationPanel, d as VisualizationPanelDynamic, V as VisualizerFactory, k as WordCloud, W as WordCloudAdapter, g as getBestIntervalMode, h as hideEmptyAnswersInData, i as intervalCalculators, t as textHelper } from './shared2.mjs'; import Plotly from 'plotly.js-dist-min'; class PlotlySetup { static setup(charType, model, answersData) { return this.setups[charType](model, answersData); } static setupPie(model, answersData) { let { datasets, labels, colors, texts, seriesLabels, } = answersData; const hasSeries = seriesLabels.length > 1 || model.question.getType() === "matrix"; const layoutColumns = 2; let traces = []; const traceConfig = { type: model.chartType, sort: false, labels: labels, customdata: labels, text: labels.map((label) => { return PlotlySetup.getTruncatedLabel(label, model.labelTruncateLength); }), hoverinfo: "value+text", textposition: "inside", texttemplate: "%{text}" }; if (model.chartType === "doughnut") { traceConfig.type = "pie"; traceConfig.hole = 0.4; } if (!hasSeries) { traceConfig.mode = "markers", traceConfig.marker = { color: colors }; traceConfig.marker.symbol = "circle"; traceConfig.marker.size = 16; } datasets.forEach((dataset, index) => { const isNotEmpty = dataset.some((value) => value != 0); let pieTexts = traceConfig.text; if (model.showPercentages) { const percentages = model.getPercentages([dataset])[0]; pieTexts = labels.map((l, li) => (model.showOnlyPercentages ? percentages[li] : PlotlySetup.getTruncatedLabel(l, model.labelTruncateLength) + "<br>" + percentages[li]) + "%"); } if (isNotEmpty) { traces.push(Object.assign({}, traceConfig, { values: dataset, text: pieTexts, domain: { column: traces.length % layoutColumns, row: Math.floor(traces.length / layoutColumns), }, title: { position: "bottom center", text: seriesLabels[index] } })); } }); const radius = labels.length < 10 ? labels.length * 50 + 100 : 550; const height = (radius + 25) * Math.ceil(traces.length / layoutColumns); const layout = { font: { family: "Segoe UI, sans-serif", size: 14, weight: "normal", color: "#404040", }, height: height, margin: { l: 0, t: 25, b: 0, r: 10, }, colorway: colors, hovermode: "closest", plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, showlegend: false, }; if (hasSeries) { layout.annotations = []; layout.grid = { rows: Math.ceil(traces.length / layoutColumns), columns: layoutColumns }; } return { traces, layout, hasSeries }; } static setupBar(model, answersData) { let lineHeight = 30; let topMargin = 30; let bottomMargin = 30; let { datasets, labels, colors, texts, seriesLabels, labelsTitle, valuesTitle } = answersData; const hasSeries = seriesLabels.length > 1 || model.question.getType() === "matrix"; const traces = []; const traceConfig = { type: model.chartType === "line" ? "line" : "bar", y: labels, customdata: labels, hoverinfo: "text", orientation: "h", textposition: "none", }; if (!hasSeries) { traceConfig.width = 0.5; traceConfig.bargap = 0.5; traceConfig.mode = "markers", traceConfig.marker = { color: colors }; } datasets.forEach((dataset, index) => { const traceName = hasSeries ? seriesLabels[index] : labels[index]; const percentString = model.showPercentages ? "%" : ""; const trace = Object.assign({}, traceConfig, { x: dataset, name: traceName, width: hasSeries && model.chartType !== "stackedbar" ? 0.5 / seriesLabels.length : 0.5, text: texts[index], hovertext: labels.map((label, labelIndex) => { if (model.showOnlyPercentages) { return `${texts[index][labelIndex]}${percentString}`; } else { return hasSeries ? `${traceName} : ${label}, ${texts[index][labelIndex]}${percentString}` : `${texts[index][labelIndex]}${percentString}, ${label}`; } }), }); if (model.showPercentages) { let texttemplate = model.showOnlyPercentages ? "%{text}%" : "%{value} (%{text}%)"; trace.textposition = "inside"; trace.texttemplate = texttemplate; trace.width = hasSeries && model.chartType !== "stackedbar" ? 0.7 / seriesLabels.length : 0.9; trace.bargap = hasSeries && model.chartType !== "stackedbar" ? 0.3 / seriesLabels.length : 0.1; } traces.push(trace); }); const height = (labels.length + 1) * lineHeight + topMargin + bottomMargin; const layout = { font: { family: "Segoe UI, sans-serif", size: 14, weight: "normal", color: "#404040", }, height: height, margin: { t: topMargin, b: bottomMargin, r: 10, }, colorway: colors, hovermode: "closest", plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, showlegend: hasSeries, barmode: hasSeries && model.chartType == "stackedbar" ? "stack" : "group", xaxis: { rangemode: "nonnegative", automargin: true, }, yaxis: { automargin: true, type: "category", orientation: "h", tickmode: "array", tickvals: labels, ticktext: labels.map((label) => { return PlotlySetup.getTruncatedLabel(label, model.labelTruncateLength) + " "; }), }, }; if (labelsTitle) { layout.yaxis.title = { text: labelsTitle }; } if (valuesTitle) { layout.xaxis.title = { text: valuesTitle }; } if (hasSeries && model.chartType !== "stackedbar") { layout.height = (labels.length * seriesLabels.length + 1) * lineHeight + topMargin + bottomMargin; } if (["ar", "fa"].indexOf(localization.currentLocale) !== -1) { layout.xaxis.autorange = "reversed"; layout.yaxis.side = "right"; layout.legend = { x: 0, y: 1, xanchor: "left", yanchor: "top" }; } return { traces, layout, hasSeries }; } static setupVBar(model, answersData) { let topMargin = 30; let bottomMargin = 30; let { datasets, labels, colors, texts, seriesLabels, labelsTitle, valuesTitle } = answersData; const hasSeries = seriesLabels.length > 1 || model.question.getType() === "matrix"; if (model.type !== "histogram" && model.type !== "pivot") { labels = [].concat(labels).reverse(); seriesLabels = [].concat(seriesLabels).reverse(); colors = [].concat(colors.slice(0, hasSeries ? seriesLabels.length : labels.length)).reverse(); const ts = []; texts.forEach(text => { ts.push([].concat(text).reverse()); }); texts = ts; const ds = []; datasets.forEach(dataset => { ds.push([].concat(dataset).reverse()); }); datasets = ds; } const traces = []; const traceConfig = { type: model.chartType === "line" ? "line" : "bar", x: labels, customdata: hasSeries ? seriesLabels : labels, hoverinfo: hasSeries ? undefined : "x+y", orientation: "v", textposition: "none", }; if (!hasSeries) { traceConfig.width = 0.5; traceConfig.bargap = 0.5; traceConfig.mode = "markers", traceConfig.marker = { color: colors }; } datasets.forEach((dataset, index) => { var trace = Object.assign({}, traceConfig, { y: dataset, name: hasSeries ? seriesLabels[index] : labels[index], text: texts[index], }); if (model.showPercentages) { let texttemplate = model.showOnlyPercentages ? "%{text}%" : "%{value} (%{text}%)"; trace.textposition = "inside"; trace.texttemplate = texttemplate; if (!hasSeries) { trace.width = 0.9; trace.bargap = 0.1; } } traces.push(trace); }); const maxTicks = 50; const tickStep = Math.ceil(labels.length / maxTicks); const layout = { font: { family: "Segoe UI, sans-serif", size: 14, weight: "normal", color: "#404040", }, margin: { t: topMargin, b: bottomMargin, r: 10, }, colorway: colors, hovermode: "closest", plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, showlegend: hasSeries, yaxis: { rangemode: "nonnegative", automargin: true, }, xaxis: { automargin: true, type: "category", tickmode: "array", tickvals: labels, ticktext: labels.map((label, index) => { if (labels.length > maxTicks && index % tickStep !== 0) { return ""; } return PlotlySetup.getTruncatedLabel(label, model.labelTruncateLength); }), }, }; if (labelsTitle) { layout.xaxis.title = { text: labelsTitle }; } if (valuesTitle) { layout.yaxis.title = { text: valuesTitle }; } if (model.showPercentages && model.showOnlyPercentages) { layout.yaxis = { automargin: true, tickformat: ".0%", range: [0, 1], ticklen: model.showOnlyPercentages ? 25 : 5, tickcolor: "transparent", }; } if (!model.getValueType || model.getValueType() != "date") { layout.xaxis = { automargin: true, type: "category", }; } return { traces, layout, hasSeries }; } static setupScatter(model, answersData) { let lineHeight = 30; let topMargin = 30; let bottomMargin = 30; let { datasets, labels, colors, texts, seriesLabels, } = answersData; const hasSeries = seriesLabels.length > 1 || model.question.getType() === "matrix"; const traces = []; const traceConfig = { type: "scatter", y: (hasSeries ? seriesLabels : labels).map((label) => { return PlotlySetup.getTruncatedLabel(label, model.labelTruncateLength); }), customdata: hasSeries ? seriesLabels : labels, text: hasSeries ? seriesLabels : labels, hoverinfo: "x+y", orientation: "h", mode: "markers", width: 0.5, marker: {}, }; if (!hasSeries) { traceConfig.marker.symbol = "circle"; traceConfig.marker.size = 16; } datasets.forEach((dataset) => { { var trace = Object.assign({}, traceConfig, { x: dataset, }); traces.push(trace); } }); const height = (labels.length + 1) * lineHeight + topMargin + bottomMargin; const layout = { font: { family: "Segoe UI, sans-serif", size: 14, weight: "normal", color: "#404040", }, height: height, margin: { t: topMargin, b: bottomMargin, r: 10, }, colorway: colors, hovermode: "closest", yaxis: { automargin: true, type: "category", ticklen: 5, tickcolor: "transparent", }, xaxis: { rangemode: "nonnegative", automargin: true, }, plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, showlegend: false, }; if (hasSeries) { layout.showlegend = true; layout.height = undefined; labels.forEach((label, index) => { traces[index].hoverinfo = "x+name"; traces[index].marker.color = undefined; traces[index].name = label; }); } return { traces, layout, hasSeries }; } static setupGauge(model, answersData) { let [level, minValue, maxValue] = answersData; if (model.question.getType() === "rating") { const rateValues = model.question.visibleRateValues; maxValue = rateValues[rateValues.length - 1].value; minValue = rateValues[0].value; } const colors = model.generateColors(maxValue, minValue, NumberModel.stepsCount); if (NumberModel.showAsPercentage) { level = DataHelper.toPercentage(level, maxValue); minValue = DataHelper.toPercentage(minValue, maxValue); maxValue = DataHelper.toPercentage(maxValue, maxValue); } var traces = [ { type: "indicator", mode: "gauge+number", gauge: { axis: { range: [minValue, maxValue] }, shape: model.chartType, bgcolor: "white", bar: { color: colors[0] }, }, value: level, text: model.name, domain: { x: [0, 1], y: [0, 1] }, }, ]; const chartMargin = model.chartType === "bullet" ? 60 : 30; var layout = { height: 250, margin: { l: chartMargin, r: chartMargin, b: chartMargin, t: chartMargin, pad: 5 }, plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, }; return { traces, layout, hasSeries: false }; } static setupRadar(model, answersData) { let { datasets, labels, colors, texts, seriesLabels, } = answersData; const hasSeries = seriesLabels.length > 1 || model.question.getType() === "matrix"; const traces = []; const traceConfig = { type: "scatterpolar", mode: "lines+markers", fill: "toself", line: { width: 2 }, marker: { size: 6 } }; datasets.forEach((dataset, index) => { const traceName = hasSeries ? seriesLabels[index] : ""; const trace = Object.assign({}, traceConfig, { r: dataset, theta: labels, name: traceName, text: texts[index], hoverinfo: "r+theta+name", customdata: labels, hovertemplate: "%{theta}: %{r}" + "<extra></extra>", line: Object.assign(Object.assign({}, traceConfig.line), { color: colors[index % colors.length] }), marker: Object.assign(Object.assign({}, traceConfig.marker), { color: colors[index % colors.length] }) }); traces.push(trace); }); const layout = { font: { family: "Segoe UI, sans-serif", size: 14, weight: "normal", color: "#404040", }, polar: { radialaxis: { visible: true, range: [0, Math.max(...datasets.map(s => Math.max(...s))) * 1.1], tickfont: { size: 12 } }, angularaxis: { tickfont: { size: 12 }, ticktext: labels.map((label) => { return PlotlySetup.getTruncatedLabel(label, model.labelTruncateLength); }), tickvals: labels } }, showlegend: hasSeries, colorway: colors, plot_bgcolor: model.backgroundColor, paper_bgcolor: model.backgroundColor, margin: { l: 50, r: 50, t: 50, b: 50 } }; return { traces, layout, hasSeries }; } } PlotlySetup.imageExportFormat = "png"; /** * Fires when end user clicks on the 'save as image' button. */ PlotlySetup.onImageSaving = new Event(); /** * Fires before plot will be created. User can change traces, layout and config of the plot. * Options is an object with the following fields: traces, layout and config of the plot. */ PlotlySetup.onPlotCreating = new Event(); PlotlySetup.setups = { bar: PlotlySetup.setupBar, vbar: PlotlySetup.setupVBar, line: PlotlySetup.setupVBar, stackedbar: PlotlySetup.setupBar, doughnut: PlotlySetup.setupPie, pie: PlotlySetup.setupPie, scatter: PlotlySetup.setupScatter, gauge: PlotlySetup.setupGauge, bullet: PlotlySetup.setupGauge, radar: PlotlySetup.setupRadar, }; PlotlySetup.getTruncatedLabel = (label, labelTruncateLength) => { const truncateSymbols = "..."; const truncateSymbolsLength = truncateSymbols.length; if (!labelTruncateLength) return label; if (labelTruncateLength === -1) return label; if (label.length <= labelTruncateLength + truncateSymbolsLength) return label; return label.substring(0, labelTruncateLength) + truncateSymbols; }; class MatrixDropdownGrouped extends SelectBase { constructor(question, data, options, name) { super(question, data, options, name || "matrixDropdownGrouped"); } get matrixQuestion() { return this.question; } get dataNames() { return this.matrixQuestion.columns.map(column => column.name); } getSeriesValues() { return this.matrixQuestion.columns.map((column) => column.name); } getSeriesLabels() { return this.matrixQuestion.columns.map((column) => column.title); } // public getSelectedItemByText(itemText: string) { // return this.matrixQuestion.columns.filter( // (column: ItemValue) => column.text === itemText // )[0]; // } valuesSource() { return this.matrixQuestion.choices; } isSupportMissingAnswers() { return false; } getCalculatedValuesCore() { const values = this.getValues(); const series = this.getSeriesValues(); const rows = this.matrixQuestion.rows.map(row => row.value); const statistics = defaultStatisticsCalculator(this.surveyData, { name: this.name, dataNames: series, dataPath: this.dataPath, getValues: () => values, getLabels: () => values, getSeriesValues: () => rows, getSeriesLabels: () => rows, }); return statistics.map(s => s[0]); } } VisualizationManager.registerVisualizer("matrixdropdown-grouped", MatrixDropdownGrouped); class SelectBasePlotly extends SelectBase { } SelectBasePlotly.types = ["bar", "vbar", "pie", "doughnut"]; SelectBasePlotly.displayModeBar = undefined; class BooleanPlotly extends BooleanModel { } BooleanPlotly.types = ["pie", "bar", "doughnut"]; class HistogramPlotly extends HistogramModel { } HistogramPlotly.types = ["vbar", "bar"]; class MatrixPlotly extends Matrix { } MatrixPlotly.types = ["bar", "stackedbar", "pie", "doughnut"]; class MatrixDropdownGroupedPlotly extends MatrixDropdownGrouped { } MatrixDropdownGroupedPlotly.types = ["stackedbar", "bar", "pie", "doughnut"]; class PivotPlotly extends PivotModel { } PivotPlotly.types = ["vbar", "bar", "line", "stackedbar", "pie", "doughnut"]; // ["vbar", "bar"]; class GaugePlotly extends NumberModel { } GaugePlotly.displayModeBar = undefined; GaugePlotly.types = ["gauge", "bullet"]; class RankingPlotly extends RankingModel { } const plotlyChartTypes = { "boolean": () => BooleanPlotly.types, "number": () => GaugePlotly.types, "selectBase": () => SelectBasePlotly.types, "histogram": () => HistogramPlotly.types, "matrix": () => MatrixPlotly.types, "matrixDropdownGrouped": () => MatrixDropdownGroupedPlotly.types, "pivot": () => PivotPlotly.types, "ranking": () => [].concat(SelectBasePlotly.types).concat(["radar"]), }; class PlotlyChartAdapter { constructor(model) { this.model = model; this._chart = undefined; } patchConfigParameters(chartNode, traces, layout, config) { if (this.model.question.getType() === "boolean") { const colors = this.model.getColors(); const boolColors = [ BooleanModel.trueColor || colors[0], BooleanModel.falseColor || colors[1], ]; if (this.model.showMissingAnswers) { boolColors.push(colors[2]); } const chartType = this.model.chartType; if (chartType === "pie" || chartType === "doughnut") { traces.forEach((trace) => { if (!trace) return; if (!trace.marker) trace.marker = {}; trace.marker.colors = boolColors; }); } else if (chartType === "bar") { traces.forEach((trace) => { if (!trace) return; if (!trace.marker) trace.marker = {}; trace.marker.color = boolColors; }); } } if (this.model.type === "number") { config.displayModeBar = true; } } get chart() { return this._chart; } getChartTypes() { const visualizerType = this.model.type; let chartCtypes = []; if (plotlyChartTypes[visualizerType]) { chartCtypes = plotlyChartTypes[visualizerType](); } return chartCtypes; } create(chartNode) { return __awaiter(this, void 0, void 0, function* () { const [plot, plotlyOptions] = yield this.update(chartNode); if (this.model instanceof SelectBase && this.model.supportSelection) { const _model = this.model; chartNode["on"]("plotly_click", (data) => { if (data.points.length > 0) { let itemText = ""; if (!plotlyOptions.hasSeries) { itemText = Array.isArray(data.points[0].customdata) ? data.points[0].customdata[0] : data.points[0].customdata; const item = _model.getSelectedItemByText(itemText); _model.setSelection(item); } else { itemText = data.points[0].data.name; const propertyLabel = data.points[0].label; const seriesValues = this.model.getSeriesValues(); const seriesLabels = this.model.getSeriesLabels(); const propertyValue = seriesValues[seriesLabels.indexOf(propertyLabel)]; const selectedItem = _model.getSelectedItemByText(itemText); const item = new ItemValue({ [propertyValue]: selectedItem.value }, propertyLabel + ": " + selectedItem.text); _model.setSelection(item); } // const itemText = plotlyOptions.hasSeries // ? data.points[0].data.name // : Array.isArray(data.points[0].customdata) // ? data.points[0].customdata[0] // : data.points[0].customdata; // const item: ItemValue = this.model.getSelectedItemByText(itemText); // this.model.setSelection(item); } }); } var getDragLayer = () => chartNode.getElementsByClassName("nsewdrag")[0]; chartNode["on"]("plotly_hover", () => { const dragLayer = getDragLayer(); dragLayer && (dragLayer.style.cursor = "pointer"); }); chartNode["on"]("plotly_unhover", () => { const dragLayer = getDragLayer(); dragLayer && (dragLayer.style.cursor = ""); }); // setTimeout(() => Plotly.Plots.resize(chartNode), 10); this._chart = plot; return plot; }); } update(chartNode) { return __awaiter(this, void 0, void 0, function* () { const answersData = (this.model instanceof SelectBase) ? yield this.model.getAnswersData() : yield this.model.getCalculatedValues(); var plotlyOptions = PlotlySetup.setup(this.model.chartType, this.model, answersData); let config = { displaylogo: false, responsive: true, locale: localization.currentLocale, modeBarButtonsToRemove: ["toImage"], modeBarButtonsToAdd: [ { name: "toImageSjs", title: localization.getString("saveDiagramAsPNG"), icon: Plotly.Icons.camera, click: (gd) => { let options = { format: PlotlySetup.imageExportFormat, // width: 800, // height: 600, filename: this.model.question.name, }; PlotlySetup.onImageSaving.fire(this.model, options); Plotly.downloadImage(gd, options); }, }, ], }; if (SelectBasePlotly.displayModeBar !== undefined) { config.displayModeBar = SelectBasePlotly.displayModeBar; } this.patchConfigParameters(chartNode, plotlyOptions.traces, plotlyOptions.layout, config); let options = { traces: plotlyOptions.traces, layout: plotlyOptions.layout, data: answersData, config: config, }; PlotlySetup.onPlotCreating.fire(this.model, options); const plot = Plotly.react(chartNode, options.traces, options.layout, options.config); return [plot, plotlyOptions]; }); } destroy(node) { if (!!node) { Plotly.purge(node); } this._chart = undefined; } } VisualizerBase.chartAdapterType = PlotlyChartAdapter; export { BooleanModel, BooleanPlotly, GaugePlotly, HistogramModel, HistogramPlotly, Matrix, MatrixDropdownGroupedPlotly, MatrixPlotly, NumberModel, PivotModel, PivotPlotly, PlotlyChartAdapter, PlotlySetup, RankingModel, RankingPlotly, SelectBase, SelectBasePlotly, VisualizationManager, VisualizerBase, localization, plotlyChartTypes }; //# sourceMappingURL=survey.analytics.mjs.map