@cosla/sensemaking-report-builder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -0
- package/bin/cli.js +12 -0
- package/build.js +308 -0
- package/data.js +724 -0
- package/package.json +54 -0
- package/src/d3.v7.min.js +2 -0
- package/src/default-translations.json +76 -0
- package/src/fonts/noto-sans-v42-latin-500.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-500.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-600.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-600.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-700.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-700.woff2 +0 -0
- package/src/fonts/noto-sans-v42-latin-regular.txt +1 -0
- package/src/fonts/noto-sans-v42-latin-regular.woff2 +0 -0
- package/src/index.mustache +304 -0
- package/src/popper.min.js +6 -0
- package/src/print.css +120 -0
- package/src/reset.css +55 -0
- package/src/script.js +1251 -0
- package/src/style.css +1609 -0
- package/src/svg/check.svg +3 -0
- package/src/svg/copy.svg +10 -0
- package/src/svg/exclamation.svg +3 -0
- package/src/svg/eye.svg +10 -0
- package/src/svg/filter.svg +3 -0
- package/src/svg/group.svg +10 -0
- package/src/svg/hamburger.svg +5 -0
- package/src/svg/list.svg +10 -0
- package/src/svg/list_alt.svg +10 -0
- package/src/svg/predict.svg +10 -0
- package/src/svg/reset.svg +3 -0
- package/src/svg/share.svg +10 -0
- package/src/svg/star.svg +10 -0
- package/src/svg/thumbs.svg +10 -0
- package/src/svg/trash.svg +3 -0
- package/src/svg/vote.svg +10 -0
- package/src/svg/x.svg +3 -0
- package/src/tippy-bundle.umd.min.js +2 -0
package/src/script.js
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Visualization logic for the report.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Represents a single Opinion within a Topic.
|
|
7
|
+
* Generated by the 'groupOpinions' function in the processing script.
|
|
8
|
+
* @typedef {Object} Opinion
|
|
9
|
+
* @property {string} text - The text content of the opinion.
|
|
10
|
+
* @property {number} count - The number of quotes assigned to this opinion.
|
|
11
|
+
* @property {string} countFormatted - Comma-separated count string.
|
|
12
|
+
* @property {string} fullID - Unique ID (topicSlug-opinionSlug) used to look up raw quotes.
|
|
13
|
+
* @property {boolean} [viewAllQuotes] - Flag indicating if there are more quotes than the sample.
|
|
14
|
+
* @property {string[]} [sampleQuotes] - A subset of quotes used for previews (if available).
|
|
15
|
+
* @property {string} [topicText] - Parent topic text (injected during frontend flattening).
|
|
16
|
+
* @property {string} [topicId] - Parent topic ID (injected during frontend flattening).
|
|
17
|
+
* @property {number} [_startIndex] - Calculated index for stacked bar visualization.
|
|
18
|
+
* @property {number} [_startCount] - Calculated offset for stacked bar visualization.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Represents a high-level Topic containing multiple opinions.
|
|
23
|
+
* @typedef {Object} Topic
|
|
24
|
+
* @property {string} topicID - Slugified ID for the topic.
|
|
25
|
+
* @property {string} text - The display title of the topic.
|
|
26
|
+
* @property {string} [summary] - Summary text for the topic.
|
|
27
|
+
* @property {number} rawQuoteCount - Total sum of quotes across all opinions in this topic.
|
|
28
|
+
* @property {string} quoteCountFormatted - Formatted unique quote count.
|
|
29
|
+
* @property {number} quoteCount - Count of unique representative IDs (participants).
|
|
30
|
+
* @property {number} opinionCount - Number of distinct opinions in this topic.
|
|
31
|
+
* @property {Opinion[]} opinions - Array of opinion objects.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Represents a DemographicItem
|
|
36
|
+
* @typedef {Object} DemographicItem
|
|
37
|
+
* @property {string} value - The display value of the demographic item group.
|
|
38
|
+
* @property {number} count - The count of demographic item.
|
|
39
|
+
* @property {number} [_startIndex] - Calculated index for stacked bar visualization.
|
|
40
|
+
* @property {number} [_startCount] - Calculated offset for stacked bar visualization.
|
|
41
|
+
* @property {number} [_pct] - Calculated percentage of the total demographic count.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Represents a Demographic breakdown
|
|
46
|
+
* @typedef {Object} Demographic
|
|
47
|
+
* @property {string} label - The display label of the demographic.
|
|
48
|
+
* @property {DemographicItem[]} values - Array of demographic key/values.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Represents the raw Quote object structure stored in quotes.json or payload.
|
|
53
|
+
* @typedef {Object} RawQuote
|
|
54
|
+
* @property {string} id - Matches the 'fullID' of an Opinion.
|
|
55
|
+
* @property {string} quote - The actual text of the user quote.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The global data payload injected by the build process.
|
|
60
|
+
* Handles both "inline" (all data in one file) and "static" (fetches quotes separately) modes.
|
|
61
|
+
* @type {{ topics: Topic[], demographics: [Demographic[]] quotes?: RawQuote[], options: { logo: string, overviewChart: string, hasToggle: boolean, sampleQuoteCount: number, topOpinionCount: number, topicColors: string[] } }}
|
|
62
|
+
*/
|
|
63
|
+
window.PAYLOAD = window.PAYLOAD || {};
|
|
64
|
+
// console.log(window.PAYLOAD);
|
|
65
|
+
|
|
66
|
+
const i18n = window.PAYLOAD.i18n || {};
|
|
67
|
+
const numberFormatter = new Intl.NumberFormat(i18n.locale || "en");
|
|
68
|
+
const percentFormatter = new Intl.NumberFormat(i18n.locale || "en", {
|
|
69
|
+
style: "percent",
|
|
70
|
+
maximumFractionDigits: 1,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Formats a number according to the active locale.
|
|
75
|
+
* @param {number} num
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function formatNumber(num) {
|
|
79
|
+
return numberFormatter.format(num);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Formats a value as a localized percentage string.
|
|
84
|
+
* @param {number|string} value
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function formatPercent(value) {
|
|
88
|
+
if (value == null || value === "") return "";
|
|
89
|
+
const num = Number(value);
|
|
90
|
+
if (isNaN(num)) return "";
|
|
91
|
+
const ratio = num > 1 ? num / 100 : num;
|
|
92
|
+
return percentFormatter.format(ratio);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Formats a count into a localized quotes string (e.g. "3 Quotes" or "3 citas").
|
|
97
|
+
* @param {number|string} count
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatQuotes(count) {
|
|
101
|
+
const formattedCount =
|
|
102
|
+
typeof count === "number" ? formatNumber(count) : count;
|
|
103
|
+
return (i18n.sections?.quotesCount || "{count} Quotes").replace(
|
|
104
|
+
"{count}",
|
|
105
|
+
formattedCount,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default color palette used for visualizing different topics.
|
|
111
|
+
* @constant {string[]}
|
|
112
|
+
*/
|
|
113
|
+
const TOPIC_PALETTE = window.PAYLOAD.options.topicColors || ["#333"];
|
|
114
|
+
const DEMOGRAPHIC_PALETTE = window.PAYLOAD.options.demographicColors || [
|
|
115
|
+
"#333",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Default number of top opinions to display in the Opinion Chart view.
|
|
120
|
+
* @constant {number}
|
|
121
|
+
*/
|
|
122
|
+
const NUM_TOP_OPINIONS = window.PAYLOAD.options.topOpinionCount || 10;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Overview chart type
|
|
126
|
+
* @constant {string}
|
|
127
|
+
*/
|
|
128
|
+
const OVERVIEW_CHART_TYPE = window.PAYLOAD.options.overviewChart || "toggle";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Quote threshold for warning
|
|
132
|
+
* @constant {number}
|
|
133
|
+
*/
|
|
134
|
+
let lowSampleThreshold = window.PAYLOAD.options.lowSampleThreshold || 30;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Global modal opinion ID
|
|
138
|
+
* @constant {string}
|
|
139
|
+
*/
|
|
140
|
+
let currentDrawerId = "";
|
|
141
|
+
/** @type {{ key: string, value: string }[]} */
|
|
142
|
+
let currentDrawerFilters = [];
|
|
143
|
+
|
|
144
|
+
/** @type {Opinion[]} */
|
|
145
|
+
let flatOpinions = [];
|
|
146
|
+
/** @type {Map<string, RawQuote[]>} */
|
|
147
|
+
let quoteMap;
|
|
148
|
+
/** @type {{ key: string, value: string }[][]} */
|
|
149
|
+
let compareGroups = [[]];
|
|
150
|
+
let activeOverviewChart = "topics";
|
|
151
|
+
|
|
152
|
+
function renderOverviewChart() {
|
|
153
|
+
if (activeOverviewChart === "opinions") createOpinionChart();
|
|
154
|
+
else createTopicChart();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Renders a demographic chart.
|
|
159
|
+
* Displays each demographic breakdown as a stacked horizontal bar using SVG.
|
|
160
|
+
* Each segment represents a demographic value, sized by its share of participants.
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
function createDemographicChart({ id, data }) {
|
|
164
|
+
/** @type {Demographic[]} */
|
|
165
|
+
const svgHeight = 16;
|
|
166
|
+
const borderRadius = 4;
|
|
167
|
+
const containerSel = d3.select(`#${id}`);
|
|
168
|
+
const render = () => {
|
|
169
|
+
containerSel.html("");
|
|
170
|
+
|
|
171
|
+
const containerNode = containerSel.node();
|
|
172
|
+
const containerWidth = containerNode
|
|
173
|
+
? containerNode.getBoundingClientRect().width
|
|
174
|
+
: 800;
|
|
175
|
+
|
|
176
|
+
data.forEach((demographic) => {
|
|
177
|
+
const otherCategory = (
|
|
178
|
+
i18n.chart?.otherCategory || "Other"
|
|
179
|
+
).toLowerCase();
|
|
180
|
+
const isOther = (val) =>
|
|
181
|
+
val.toLowerCase() === "other" || val.toLowerCase() === otherCategory;
|
|
182
|
+
const sortedValues = [...demographic.values].sort((a, b) => {
|
|
183
|
+
if (isOther(a.value)) return 1;
|
|
184
|
+
if (isOther(b.value)) return -1;
|
|
185
|
+
return d3.ascending(a.value.toLowerCase(), b.value.toLowerCase());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Total count across all values for this demographic (denominator for %)
|
|
189
|
+
const totalCount = d3.sum(sortedValues, (d) => d.count);
|
|
190
|
+
|
|
191
|
+
// Calculate cumulative offsets for the stacked bar effect
|
|
192
|
+
let currentAccumulator = 0;
|
|
193
|
+
const processedValues = sortedValues.map((d, i) => {
|
|
194
|
+
const startVal = currentAccumulator;
|
|
195
|
+
currentAccumulator += d.count;
|
|
196
|
+
return {
|
|
197
|
+
...d,
|
|
198
|
+
_startIndex: i,
|
|
199
|
+
_startCount: startVal,
|
|
200
|
+
_pct: Math.round((d.count / totalCount) * 100),
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const top = processedValues[0];
|
|
205
|
+
|
|
206
|
+
const wrapper = containerSel
|
|
207
|
+
.append("div")
|
|
208
|
+
.attr("class", "demographic-wrapper");
|
|
209
|
+
|
|
210
|
+
wrapper
|
|
211
|
+
.append("div")
|
|
212
|
+
.attr("class", "demographic-header")
|
|
213
|
+
.text(demographic.label);
|
|
214
|
+
|
|
215
|
+
const svg = wrapper
|
|
216
|
+
.append("svg")
|
|
217
|
+
.attr("class", "demographic-svg")
|
|
218
|
+
.attr("width", "100%")
|
|
219
|
+
.attr("height", svgHeight);
|
|
220
|
+
|
|
221
|
+
const gapSize = containerWidth > 600 ? 4 : 2;
|
|
222
|
+
|
|
223
|
+
const totalRowGapPixels = Math.max(
|
|
224
|
+
0,
|
|
225
|
+
(processedValues.length - 1) * gapSize,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
svg
|
|
229
|
+
.selectAll("rect")
|
|
230
|
+
.data(processedValues)
|
|
231
|
+
.enter()
|
|
232
|
+
.append("rect")
|
|
233
|
+
.attr("class", "demographic-rect")
|
|
234
|
+
.attr("y", 0)
|
|
235
|
+
.attr("height", svgHeight)
|
|
236
|
+
.attr("x", (d) => {
|
|
237
|
+
const startRatio = d._startCount / totalCount;
|
|
238
|
+
const shiftRight = d._startIndex * gapSize;
|
|
239
|
+
const shiftLeft = startRatio * totalRowGapPixels;
|
|
240
|
+
|
|
241
|
+
return `calc(${startRatio * 100}% + ${shiftRight - shiftLeft}px)`;
|
|
242
|
+
})
|
|
243
|
+
.attr("width", (d) => {
|
|
244
|
+
const ratio = d.count / totalCount;
|
|
245
|
+
const shrinkPixels = ratio * totalRowGapPixels;
|
|
246
|
+
return `calc(${ratio * 100}% - ${shrinkPixels}px)`;
|
|
247
|
+
})
|
|
248
|
+
.attr("rx", borderRadius)
|
|
249
|
+
.attr(
|
|
250
|
+
"fill",
|
|
251
|
+
(d, i) => DEMOGRAPHIC_PALETTE[i % DEMOGRAPHIC_PALETTE.length],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const legend = wrapper.append("div").attr("class", "demographic-legend");
|
|
255
|
+
|
|
256
|
+
processedValues.forEach((d, i) => {
|
|
257
|
+
const item = legend.append("div").attr("class", "legend-item");
|
|
258
|
+
item
|
|
259
|
+
.append("span")
|
|
260
|
+
.attr("class", "legend-color")
|
|
261
|
+
.style(
|
|
262
|
+
"background",
|
|
263
|
+
DEMOGRAPHIC_PALETTE[i % DEMOGRAPHIC_PALETTE.length],
|
|
264
|
+
);
|
|
265
|
+
item
|
|
266
|
+
.append("span")
|
|
267
|
+
.attr("class", "legend-text")
|
|
268
|
+
.html(`<strong>${d.value}</strong> ${formatPercent(d._pct)}`);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
render();
|
|
274
|
+
|
|
275
|
+
let resizeTimer;
|
|
276
|
+
const onResize = () => {
|
|
277
|
+
clearTimeout(resizeTimer);
|
|
278
|
+
resizeTimer = setTimeout(render, 250);
|
|
279
|
+
};
|
|
280
|
+
window.addEventListener("resize", onResize);
|
|
281
|
+
return () => window.removeEventListener("resize", onResize);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Returns an array of groups, each with a label and topics array.
|
|
286
|
+
* When no compare groups are active, returns a single group for all participants.
|
|
287
|
+
* @returns {{ label: string, topics: Topic[] }[]}
|
|
288
|
+
*/
|
|
289
|
+
function getTopicData() {
|
|
290
|
+
const GROUP_LABELS = ["A", "B", "C", "D"];
|
|
291
|
+
|
|
292
|
+
const groupDescription = (group) =>
|
|
293
|
+
group.length === 0
|
|
294
|
+
? "All Participants"
|
|
295
|
+
: group
|
|
296
|
+
.map(
|
|
297
|
+
(f) => `<div class="topic-group-item">${f.key}: ${f.value}</div>`,
|
|
298
|
+
)
|
|
299
|
+
.join("");
|
|
300
|
+
|
|
301
|
+
if (compareGroups.length === 1 && compareGroups[0].length === 0) {
|
|
302
|
+
return [
|
|
303
|
+
{
|
|
304
|
+
label: "",
|
|
305
|
+
description: groupDescription(compareGroups[0]),
|
|
306
|
+
topics: window.PAYLOAD.topics,
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// loop through each group and filter opinions based on the group's demographic filters. Then aggregate quote counts for each opinion and topic, and return a new structured array that maps to the original topic structure.
|
|
312
|
+
return compareGroups.map((group, i) => {
|
|
313
|
+
const topics = window.PAYLOAD.topics
|
|
314
|
+
.map((topic) => {
|
|
315
|
+
const filteredOpinions = topic.opinions
|
|
316
|
+
.map((opinion) => {
|
|
317
|
+
const allQuotes = quoteMap?.get(opinion.fullID) ?? [];
|
|
318
|
+
const matched =
|
|
319
|
+
group.length === 0
|
|
320
|
+
? allQuotes
|
|
321
|
+
: allQuotes.filter((q) =>
|
|
322
|
+
group.every(({ key, value }) => q[key] === value),
|
|
323
|
+
);
|
|
324
|
+
return {
|
|
325
|
+
...opinion,
|
|
326
|
+
count: matched.length,
|
|
327
|
+
countFormatted: formatNumber(matched.length),
|
|
328
|
+
};
|
|
329
|
+
})
|
|
330
|
+
.filter((o) => o.count > 0);
|
|
331
|
+
|
|
332
|
+
const rawQuoteCount = d3.sum(filteredOpinions, (o) => o.count);
|
|
333
|
+
return {
|
|
334
|
+
...topic,
|
|
335
|
+
opinions: filteredOpinions,
|
|
336
|
+
opinionCount: filteredOpinions.length,
|
|
337
|
+
rawQuoteCount,
|
|
338
|
+
quoteCount: rawQuoteCount,
|
|
339
|
+
quoteCountFormatted: formatNumber(rawQuoteCount),
|
|
340
|
+
};
|
|
341
|
+
})
|
|
342
|
+
.filter((t) => t.rawQuoteCount > 0);
|
|
343
|
+
|
|
344
|
+
const groupPrefix = i18n.filterModal?.groupLabelPrefix || "Group";
|
|
345
|
+
return {
|
|
346
|
+
label: `${groupPrefix} ${GROUP_LABELS[i]}`,
|
|
347
|
+
description: groupDescription(group),
|
|
348
|
+
topics,
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Renders the primary "Conversation Overview" chart.
|
|
355
|
+
* Displays topics as rows of stacked horizontal bars using SVG.
|
|
356
|
+
* When multiple compare groups are active, each topic shows one bar per group
|
|
357
|
+
* at half height; a single group uses full height.
|
|
358
|
+
*/
|
|
359
|
+
function createTopicChart() {
|
|
360
|
+
const groups = getTopicData();
|
|
361
|
+
const svgHeight = 32;
|
|
362
|
+
const barHeight = groups.length > 1 ? svgHeight / 2 : svgHeight;
|
|
363
|
+
const borderRadius = groups.length > 1 ? 2 : 4;
|
|
364
|
+
const containerSel = d3.select(`#conversation-overview-chart`);
|
|
365
|
+
|
|
366
|
+
const render = () => {
|
|
367
|
+
containerSel.html("");
|
|
368
|
+
|
|
369
|
+
const containerNode = containerSel.node();
|
|
370
|
+
const containerWidth = containerNode
|
|
371
|
+
? containerNode.getBoundingClientRect().width
|
|
372
|
+
: 800;
|
|
373
|
+
|
|
374
|
+
const gapSize = containerWidth > 600 ? 4 : 2;
|
|
375
|
+
|
|
376
|
+
const maxCount = d3.max(
|
|
377
|
+
groups.flatMap((g) => g.topics),
|
|
378
|
+
(d) => d.rawQuoteCount,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const sortedTopicIDs = [...groups[0].topics]
|
|
382
|
+
.sort((a, b) => d3.descending(a.rawQuoteCount, b.rawQuoteCount))
|
|
383
|
+
.map((t) => t.topicID);
|
|
384
|
+
|
|
385
|
+
sortedTopicIDs.forEach((topicID, topicIdx) => {
|
|
386
|
+
const refTopic = groups[0].topics.find((t) => t.topicID === topicID);
|
|
387
|
+
if (!refTopic) return;
|
|
388
|
+
|
|
389
|
+
const topicColor = TOPIC_PALETTE[topicIdx % TOPIC_PALETTE.length];
|
|
390
|
+
const hoverColor = d3.color(topicColor).darker(0.8);
|
|
391
|
+
|
|
392
|
+
const wrapper = containerSel.append("div").attr("class", "topic-wrapper");
|
|
393
|
+
|
|
394
|
+
const opinionsCountText = (
|
|
395
|
+
i18n.chart?.opinionsMeta || "({count} opinions)"
|
|
396
|
+
).replace("{count}", formatNumber(refTopic.opinionCount));
|
|
397
|
+
|
|
398
|
+
wrapper
|
|
399
|
+
.append("div")
|
|
400
|
+
.attr("class", "topic-header")
|
|
401
|
+
.html(
|
|
402
|
+
`${refTopic.text} <span class="topic-meta">${opinionsCountText}</span>`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
groups.forEach(({ label, topics, description }) => {
|
|
406
|
+
const topic = topics.find((t) => t.topicID === topicID);
|
|
407
|
+
if (!topic) return;
|
|
408
|
+
|
|
409
|
+
const sortedOpinions = [...topic.opinions].sort((a, b) =>
|
|
410
|
+
d3.descending(a.count, b.count),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
let currentAccumulator = 0;
|
|
414
|
+
const processedOpinions = sortedOpinions.map((d, i) => {
|
|
415
|
+
const startVal = currentAccumulator;
|
|
416
|
+
currentAccumulator += d.count;
|
|
417
|
+
return { ...d, _startIndex: i, _startCount: startVal };
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const totalRowGapPixels = Math.max(
|
|
421
|
+
0,
|
|
422
|
+
(processedOpinions.length - 1) * gapSize,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const groupRow = wrapper.append("div").attr("class", "topic-group");
|
|
426
|
+
|
|
427
|
+
groupRow
|
|
428
|
+
.append("div")
|
|
429
|
+
.attr("class", "topic-group-label")
|
|
430
|
+
.attr(
|
|
431
|
+
"data-tippy-content",
|
|
432
|
+
`<div class="topic-group-label">${label}</div><div class="topic-group-description">${description}</div>`,
|
|
433
|
+
)
|
|
434
|
+
.text(label);
|
|
435
|
+
|
|
436
|
+
const svg = groupRow
|
|
437
|
+
.append("svg")
|
|
438
|
+
.attr("class", "topic-svg")
|
|
439
|
+
.attr("width", "100%")
|
|
440
|
+
.attr("height", barHeight);
|
|
441
|
+
|
|
442
|
+
svg
|
|
443
|
+
.selectAll("rect")
|
|
444
|
+
.data(processedOpinions)
|
|
445
|
+
.enter()
|
|
446
|
+
.append("rect")
|
|
447
|
+
.attr("class", "opinion-rect")
|
|
448
|
+
.attr("y", 0)
|
|
449
|
+
.attr("height", barHeight)
|
|
450
|
+
.attr("x", (d) => {
|
|
451
|
+
const startRatio = d._startCount / maxCount;
|
|
452
|
+
const shiftRight = d._startIndex * gapSize;
|
|
453
|
+
const shiftLeft = startRatio * totalRowGapPixels;
|
|
454
|
+
return `calc(${startRatio * 100}% + ${shiftRight - shiftLeft}px)`;
|
|
455
|
+
})
|
|
456
|
+
.attr("width", (d) => {
|
|
457
|
+
const ratio = d.count / maxCount;
|
|
458
|
+
const shrinkPixels = ratio * totalRowGapPixels;
|
|
459
|
+
return `calc(${ratio * 100}% - ${shrinkPixels}px)`;
|
|
460
|
+
})
|
|
461
|
+
.attr("fill", topicColor)
|
|
462
|
+
.on("mouseover", function () {
|
|
463
|
+
d3.select(this).attr("fill", hoverColor);
|
|
464
|
+
})
|
|
465
|
+
.on("mouseout", function () {
|
|
466
|
+
d3.select(this).attr("fill", topicColor);
|
|
467
|
+
})
|
|
468
|
+
.attr("rx", borderRadius)
|
|
469
|
+
.attr("data-tippy-content", (d) => {
|
|
470
|
+
return `
|
|
471
|
+
<div class="topic-text">${topic.text}</div>
|
|
472
|
+
<div class="quote-count">${formatQuotes(d.count)}</div>
|
|
473
|
+
<div class="opinion-text">${d.text}</div>
|
|
474
|
+
`;
|
|
475
|
+
})
|
|
476
|
+
.style("cursor", "pointer");
|
|
477
|
+
|
|
478
|
+
const quotesLabel = formatQuotes(
|
|
479
|
+
topic.quoteCountFormatted || formatNumber(topic.quoteCount),
|
|
480
|
+
);
|
|
481
|
+
const lowSampleText = i18n.chart?.lowSampleSize || "Low sample size";
|
|
482
|
+
|
|
483
|
+
groupRow
|
|
484
|
+
.append("div")
|
|
485
|
+
.attr("class", "topic-group-count")
|
|
486
|
+
.html(
|
|
487
|
+
`${quotesLabel}${topic.quoteCount < lowSampleThreshold ? `<img src="svg/exclamation.svg" alt="" role="presentation" data-tippy-content="<div class='low-sample'>${lowSampleText}</div>" />` : ""}`,
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
tippy(".opinion-rect", {
|
|
493
|
+
allowHTML: true,
|
|
494
|
+
animation: "fade",
|
|
495
|
+
theme: "light",
|
|
496
|
+
maxWidth: 280,
|
|
497
|
+
delay: 0,
|
|
498
|
+
duration: 0,
|
|
499
|
+
followCursor: true,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
tippy(".topic-group-label", {
|
|
503
|
+
allowHTML: true,
|
|
504
|
+
animation: "fade",
|
|
505
|
+
theme: "light",
|
|
506
|
+
delay: 0,
|
|
507
|
+
duration: 0,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
tippy(".topic-group-count img", {
|
|
511
|
+
allowHTML: true,
|
|
512
|
+
animation: "fade",
|
|
513
|
+
theme: "light",
|
|
514
|
+
delay: 0,
|
|
515
|
+
duration: 0,
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
render();
|
|
520
|
+
|
|
521
|
+
let resizeTimer;
|
|
522
|
+
window.addEventListener("resize", () => {
|
|
523
|
+
clearTimeout(resizeTimer);
|
|
524
|
+
resizeTimer = setTimeout(render, 250);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Renders the "Participant Overview" chart.
|
|
530
|
+
*/
|
|
531
|
+
function createParticipantChart() {
|
|
532
|
+
/** @type {Demographic[]} */
|
|
533
|
+
const data = window.PAYLOAD.demographics;
|
|
534
|
+
const id = "participant-overview-chart";
|
|
535
|
+
createDemographicChart({ id, data });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Renders the alternative "Top Opinions" view.
|
|
540
|
+
* Flattens the nested data structure (Topic -> Opinions) into a single list
|
|
541
|
+
* to display the most popular opinions regardless of topic.
|
|
542
|
+
*/
|
|
543
|
+
function createOpinionChart() {
|
|
544
|
+
const groups = getTopicData();
|
|
545
|
+
const borderRadius = groups.length > 1 ? 2 : 4;
|
|
546
|
+
const barHeight = 24;
|
|
547
|
+
const actualBarHeight = groups.length > 1 ? barHeight / 1.5 : barHeight;
|
|
548
|
+
const gapSize = 12;
|
|
549
|
+
const containerSel = d3.select(`#conversation-overview-chart`);
|
|
550
|
+
|
|
551
|
+
// Color lookup keyed by topicID, using original payload order for consistency
|
|
552
|
+
const topicIndexMap = new Map();
|
|
553
|
+
window.PAYLOAD.topics.forEach((topic, i) =>
|
|
554
|
+
topicIndexMap.set(topic.topicID, i),
|
|
555
|
+
);
|
|
556
|
+
const topicColor = (topicId) => {
|
|
557
|
+
const idx = topicIndexMap.get(topicId) ?? 0;
|
|
558
|
+
return TOPIC_PALETTE[idx % TOPIC_PALETTE.length];
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// Per-group flat map of fullID -> opinion (with injected topic metadata)
|
|
562
|
+
const groupOpinionMaps = groups.map(({ topics }) => {
|
|
563
|
+
const map = new Map();
|
|
564
|
+
topics.forEach((topic) => {
|
|
565
|
+
topic.opinions.forEach((op) => {
|
|
566
|
+
map.set(op.fullID, {
|
|
567
|
+
...op,
|
|
568
|
+
topicText: topic.text,
|
|
569
|
+
topicId: topic.topicID,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
return map;
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Top N opinion IDs ranked by the reference group (group 0)
|
|
577
|
+
const topOpinionIDs = [...groupOpinionMaps[0].values()]
|
|
578
|
+
.sort((a, b) => d3.descending(a.count, b.count))
|
|
579
|
+
.slice(0, NUM_TOP_OPINIONS)
|
|
580
|
+
.map((o) => o.fullID);
|
|
581
|
+
|
|
582
|
+
const render = () => {
|
|
583
|
+
containerSel.html("");
|
|
584
|
+
|
|
585
|
+
const maxCount = d3.max(
|
|
586
|
+
topOpinionIDs.flatMap((id) =>
|
|
587
|
+
groups.map((_, gi) => groupOpinionMaps[gi].get(id)?.count ?? 0),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
// Legend: topics represented in the top opinions
|
|
592
|
+
const topicIDsInTop = new Set(
|
|
593
|
+
topOpinionIDs
|
|
594
|
+
.map((id) => groupOpinionMaps[0].get(id)?.topicId)
|
|
595
|
+
.filter(Boolean),
|
|
596
|
+
);
|
|
597
|
+
const legend = containerSel.append("div").attr("class", "opinion-legend");
|
|
598
|
+
window.PAYLOAD.topics.forEach((topic) => {
|
|
599
|
+
if (!topicIDsInTop.has(topic.topicID)) return;
|
|
600
|
+
const item = legend.append("div").attr("class", "legend-item");
|
|
601
|
+
item
|
|
602
|
+
.append("span")
|
|
603
|
+
.attr("class", "legend-color")
|
|
604
|
+
.style("background-color", topicColor(topic.topicID));
|
|
605
|
+
item.append("span").attr("class", "legend-text").text(topic.text);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const listContainer = containerSel
|
|
609
|
+
.append("div")
|
|
610
|
+
.attr("class", "opinion-list-container");
|
|
611
|
+
|
|
612
|
+
topOpinionIDs.forEach((fullID) => {
|
|
613
|
+
const refOp = groupOpinionMaps[0].get(fullID);
|
|
614
|
+
if (!refOp) return;
|
|
615
|
+
|
|
616
|
+
const color = topicColor(refOp.topicId);
|
|
617
|
+
const hoverColor = d3.color(color).darker(0.8);
|
|
618
|
+
|
|
619
|
+
const opinionRow = listContainer
|
|
620
|
+
.append("div")
|
|
621
|
+
.attr("class", "opinion-row")
|
|
622
|
+
.style("margin-bottom", `${gapSize}px`);
|
|
623
|
+
|
|
624
|
+
opinionRow.append("div").attr("class", "opinion-label").text(refOp.text);
|
|
625
|
+
|
|
626
|
+
groups.forEach(({ label, description }, gi) => {
|
|
627
|
+
const op = groupOpinionMaps[gi].get(fullID);
|
|
628
|
+
const count = op?.count ?? 0;
|
|
629
|
+
const countFormatted = op?.countFormatted ?? "0";
|
|
630
|
+
const quotesLabel = formatQuotes(countFormatted);
|
|
631
|
+
const lowSampleText = i18n.chart?.lowSampleSize || "Low sample size";
|
|
632
|
+
|
|
633
|
+
const groupRow = opinionRow.append("div").attr("class", "topic-group");
|
|
634
|
+
|
|
635
|
+
groupRow
|
|
636
|
+
.append("div")
|
|
637
|
+
.attr("class", "topic-group-label")
|
|
638
|
+
.attr(
|
|
639
|
+
"data-tippy-content",
|
|
640
|
+
`<div class="topic-group-label">${label}</div><div class="topic-group-description">${description}</div>`,
|
|
641
|
+
)
|
|
642
|
+
.text(label);
|
|
643
|
+
|
|
644
|
+
const barWrapper = groupRow
|
|
645
|
+
.append("div")
|
|
646
|
+
.attr("class", "opinion-bar-wrapper");
|
|
647
|
+
|
|
648
|
+
barWrapper
|
|
649
|
+
.append("div")
|
|
650
|
+
.attr("class", "opinion-bar")
|
|
651
|
+
.style("height", `${actualBarHeight}px`)
|
|
652
|
+
.style("width", `${(count / maxCount) * 100}%`)
|
|
653
|
+
.style("background-color", color)
|
|
654
|
+
.style("border-radius", `${borderRadius}px`)
|
|
655
|
+
.style("cursor", "pointer")
|
|
656
|
+
.attr(
|
|
657
|
+
"data-tippy-content",
|
|
658
|
+
`
|
|
659
|
+
<div class="topic-text">${refOp.topicText}</div>
|
|
660
|
+
<div class="quote-count">${quotesLabel}</div>
|
|
661
|
+
<div class="opinion-text">${refOp.text}</div>
|
|
662
|
+
`,
|
|
663
|
+
)
|
|
664
|
+
.on("mouseover", function () {
|
|
665
|
+
d3.select(this).style("background-color", hoverColor);
|
|
666
|
+
})
|
|
667
|
+
.on("mouseout", function () {
|
|
668
|
+
d3.select(this).style("background-color", color);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
groupRow
|
|
672
|
+
.append("div")
|
|
673
|
+
.attr("class", "topic-group-count")
|
|
674
|
+
.html(
|
|
675
|
+
`${quotesLabel}${count < lowSampleThreshold ? `<img src="svg/exclamation.svg" alt="" role="presentation" data-tippy-content="<div class='low-sample'>${lowSampleText}</div>" />` : ""}`,
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
tippy(".opinion-bar", {
|
|
681
|
+
allowHTML: true,
|
|
682
|
+
animation: "fade",
|
|
683
|
+
theme: "light",
|
|
684
|
+
maxWidth: 280,
|
|
685
|
+
delay: 0,
|
|
686
|
+
duration: 0,
|
|
687
|
+
followCursor: true,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
tippy(".topic-group-label", {
|
|
691
|
+
allowHTML: true,
|
|
692
|
+
animation: "fade",
|
|
693
|
+
theme: "light",
|
|
694
|
+
delay: 0,
|
|
695
|
+
duration: 0,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
tippy(".topic-group-count img", {
|
|
699
|
+
allowHTML: true,
|
|
700
|
+
animation: "fade",
|
|
701
|
+
theme: "light",
|
|
702
|
+
delay: 0,
|
|
703
|
+
duration: 0,
|
|
704
|
+
});
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
render();
|
|
708
|
+
|
|
709
|
+
let resizeTimer;
|
|
710
|
+
window.addEventListener("resize", () => {
|
|
711
|
+
clearTimeout(resizeTimer);
|
|
712
|
+
resizeTimer = setTimeout(render, 250);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Renders a Donut Chart for a specific topic.
|
|
718
|
+
* @param {Topic} topicData - The topic data object.
|
|
719
|
+
* @param {number} index - The index of the topic (used for consistent coloring).
|
|
720
|
+
*/
|
|
721
|
+
function createDonutChart(topicData, index) {
|
|
722
|
+
const containerSel = d3.select(
|
|
723
|
+
`#topic-${topicData.topicID} .donut-chart-content`,
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const gapSizeAngle = 0.025;
|
|
727
|
+
const cornerRadius = 2;
|
|
728
|
+
const thicknessRatio = 0.375;
|
|
729
|
+
|
|
730
|
+
const render = () => {
|
|
731
|
+
containerSel.html("");
|
|
732
|
+
|
|
733
|
+
const containerNode = containerSel.node();
|
|
734
|
+
const containerWidth = containerNode
|
|
735
|
+
? containerNode.getBoundingClientRect().width
|
|
736
|
+
: 200;
|
|
737
|
+
|
|
738
|
+
const width = containerWidth;
|
|
739
|
+
const height = containerWidth;
|
|
740
|
+
const radius = Math.min(width, height) / 2;
|
|
741
|
+
|
|
742
|
+
const sortedOpinions = [...topicData.opinions].sort(
|
|
743
|
+
(a, b) => b.count - a.count,
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const svg = containerSel
|
|
747
|
+
.append("svg")
|
|
748
|
+
.attr("class", "donut-svg")
|
|
749
|
+
.attr("width", width)
|
|
750
|
+
.attr("height", height)
|
|
751
|
+
.append("g")
|
|
752
|
+
.attr("transform", `translate(${width / 2},${height / 2})`);
|
|
753
|
+
|
|
754
|
+
const pie = d3
|
|
755
|
+
.pie()
|
|
756
|
+
.value((d) => d.count)
|
|
757
|
+
.sort(null)
|
|
758
|
+
.padAngle(gapSizeAngle);
|
|
759
|
+
|
|
760
|
+
const arc = d3
|
|
761
|
+
.arc()
|
|
762
|
+
.innerRadius(radius * (1 - thicknessRatio))
|
|
763
|
+
.outerRadius(radius)
|
|
764
|
+
.cornerRadius(cornerRadius);
|
|
765
|
+
|
|
766
|
+
const baseColor = TOPIC_PALETTE[index % TOPIC_PALETTE.length];
|
|
767
|
+
const hoverColor = d3.color(baseColor).darker(0.8);
|
|
768
|
+
|
|
769
|
+
svg
|
|
770
|
+
.selectAll("path")
|
|
771
|
+
.data(pie(sortedOpinions))
|
|
772
|
+
.enter()
|
|
773
|
+
.append("path")
|
|
774
|
+
.attr("class", "opinion-segment")
|
|
775
|
+
.attr("d", arc)
|
|
776
|
+
.attr("fill", baseColor)
|
|
777
|
+
.attr("stroke", "none")
|
|
778
|
+
.on("mouseover", function () {
|
|
779
|
+
d3.select(this).attr("fill", hoverColor);
|
|
780
|
+
})
|
|
781
|
+
.on("mouseout", function () {
|
|
782
|
+
d3.select(this).attr("fill", baseColor);
|
|
783
|
+
})
|
|
784
|
+
.style("cursor", "pointer")
|
|
785
|
+
.attr("data-tippy-content", (d) => {
|
|
786
|
+
const opinion = d.data;
|
|
787
|
+
return `
|
|
788
|
+
<div class="topic-text">${topicData.text}</div>
|
|
789
|
+
<div class="quote-count">${formatQuotes(opinion.countFormatted || opinion.count)}</div>
|
|
790
|
+
<div class="opinion-text">${opinion.text}</div>
|
|
791
|
+
`;
|
|
792
|
+
});
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const initTooltips = () => {
|
|
796
|
+
tippy(`#topic-${topicData.topicID} .opinion-segment`, {
|
|
797
|
+
allowHTML: true,
|
|
798
|
+
animation: "fade",
|
|
799
|
+
theme: "light",
|
|
800
|
+
maxWidth: 280,
|
|
801
|
+
delay: 0,
|
|
802
|
+
duration: 0,
|
|
803
|
+
followCursor: true,
|
|
804
|
+
});
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
render();
|
|
808
|
+
initTooltips();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Initializes donut charts for all available topics.
|
|
813
|
+
*/
|
|
814
|
+
function createDonutCharts() {
|
|
815
|
+
const topics = window.PAYLOAD.topics;
|
|
816
|
+
topics.forEach(createDonutChart);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Populates and opens the modal to display all quotes.
|
|
821
|
+
* @param {Object} params
|
|
822
|
+
* @param {string[]} params.quotes - Array of quote strings.
|
|
823
|
+
* @param {string} params.text - The opinion text used as the header.
|
|
824
|
+
*/
|
|
825
|
+
function renderQuotesDrawer({ quotes, text }) {
|
|
826
|
+
const drawerSel = d3.select("#drawer");
|
|
827
|
+
const quoteCount = formatNumber(quotes.length);
|
|
828
|
+
drawerSel.select(".drawer-quote-count").text(quoteCount);
|
|
829
|
+
drawerSel.select(".drawer-opinion").text(text);
|
|
830
|
+
const quotesList = drawerSel.select(".drawer-quotes");
|
|
831
|
+
quotesList.html("");
|
|
832
|
+
|
|
833
|
+
if (!quotes.length) {
|
|
834
|
+
const noQuotesText =
|
|
835
|
+
i18n.drawer?.noQuotes || "No quotes for the selected filters.";
|
|
836
|
+
quotesList.html(`<li>${noQuotesText}</li>`);
|
|
837
|
+
} else {
|
|
838
|
+
// const demoProps = window.PAYLOAD.demographics.map((d) => d.label);
|
|
839
|
+
quotes.forEach((q) => {
|
|
840
|
+
// const demoHtml = demoProps
|
|
841
|
+
// .map((prop) => `<span class="demographics--value">${q[prop]}</span>`)
|
|
842
|
+
// .join("");
|
|
843
|
+
quotesList.append("li").html(`<span class="text">${q.quote}</span>`);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
drawerSel.classed("is-open", true);
|
|
847
|
+
quotesList.node().scrollTop = 0;
|
|
848
|
+
|
|
849
|
+
// focus on close button for accessibility
|
|
850
|
+
drawerSel.select(".button-close").node().focus();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Filters quotes for the current drawer opinion by `currentDrawerFilters` and re-renders.
|
|
855
|
+
* Call this whenever `currentDrawerId` or `currentDrawerFilters` changes.
|
|
856
|
+
*/
|
|
857
|
+
function updateQuotesDrawer() {
|
|
858
|
+
const unknownOpinionText = i18n.drawer?.unknownOpinion || "Unknown Opinion";
|
|
859
|
+
const match = flatOpinions.find((o) => o.fullID === currentDrawerId);
|
|
860
|
+
const text = match?.text || unknownOpinionText;
|
|
861
|
+
const quotesRaw = quoteMap.get(currentDrawerId);
|
|
862
|
+
const quotes =
|
|
863
|
+
currentDrawerFilters.length === 0
|
|
864
|
+
? quotesRaw
|
|
865
|
+
: quotesRaw.filter((d) =>
|
|
866
|
+
currentDrawerFilters.every(({ key, value }) => d[key] === value),
|
|
867
|
+
);
|
|
868
|
+
renderQuotesDrawer({ quotes, text });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function createQuotesDrawerParticipantChart() {
|
|
872
|
+
const id = "drawer-demographics-chart";
|
|
873
|
+
const unknownOpinionText = i18n.drawer?.unknownOpinion || "Unknown Opinion";
|
|
874
|
+
const match = flatOpinions.find((o) => o.fullID === currentDrawerId);
|
|
875
|
+
const text = match?.text || unknownOpinionText;
|
|
876
|
+
const quotesRaw = quoteMap.get(currentDrawerId);
|
|
877
|
+
|
|
878
|
+
const data = window.PAYLOAD.demographics.map(({ label }) => {
|
|
879
|
+
const counts = new Map();
|
|
880
|
+
quotesRaw.forEach((q) => {
|
|
881
|
+
const val = q[label];
|
|
882
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
883
|
+
counts.set(val, (counts.get(val) || 0) + 1);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
let values = Array.from(counts, ([value, count]) => ({ value, count }))
|
|
887
|
+
.filter(({ value, count }) => count > 0 && value !== "0")
|
|
888
|
+
.sort((a, b) => b.count - a.count);
|
|
889
|
+
|
|
890
|
+
if (values.length > 6) {
|
|
891
|
+
const otherCount = values.slice(5).reduce((acc, v) => acc + v.count, 0);
|
|
892
|
+
const otherText = i18n.chart?.otherCategory || "Other";
|
|
893
|
+
values = [...values.slice(0, 5), { value: otherText, count: otherCount }];
|
|
894
|
+
}
|
|
895
|
+
return { label, values };
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
return createDemographicChart({ id, data });
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Loads quote data based on the build strategy.
|
|
903
|
+
* 1. Checks `window.PAYLOAD.quotes` (Inline strategy used in `data-inline.json`).
|
|
904
|
+
* 2. If missing, fetches `quotes.json` (Static strategy used in `data-static.json`).
|
|
905
|
+
*
|
|
906
|
+
* It transforms the flat quote list into a Map keyed by `fullID` (topicSlug-opinionSlug).
|
|
907
|
+
*
|
|
908
|
+
* @returns {Promise<Map<string, string[]>>} Map where Key = fullID, Value = Array of quote strings.
|
|
909
|
+
*/
|
|
910
|
+
async function loadQuotesData() {
|
|
911
|
+
let quotes = window.PAYLOAD.quotes;
|
|
912
|
+
if (!quotes) {
|
|
913
|
+
try {
|
|
914
|
+
const response = await fetch("quotes.json");
|
|
915
|
+
if (!response.ok) throw new Error(`status: ${response.status}`);
|
|
916
|
+
quotes = await response.json();
|
|
917
|
+
} catch (error) {
|
|
918
|
+
console.error("Could not load quotes.json:", error);
|
|
919
|
+
return new Map();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Create lookup map for quick access by opinion.fullID
|
|
924
|
+
quoteMap = new Map();
|
|
925
|
+
quotes.forEach((q) => {
|
|
926
|
+
if (!quoteMap.has(q.id)) {
|
|
927
|
+
quoteMap.set(q.id, []);
|
|
928
|
+
}
|
|
929
|
+
quoteMap.get(q.id).push({ ...q });
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Initializes the Quotes Modal logic.
|
|
935
|
+
* Binds click events to ".button-quotes" to look up quotes via the Opinion's `fullID`.
|
|
936
|
+
*/
|
|
937
|
+
async function createQuotesDrawer() {
|
|
938
|
+
// Flatten opinions to find metadata by ID easily
|
|
939
|
+
flatOpinions = window.PAYLOAD.topics.map((topic) => topic.opinions).flat();
|
|
940
|
+
await loadQuotesData();
|
|
941
|
+
|
|
942
|
+
let cleanupDemographicChart = null;
|
|
943
|
+
|
|
944
|
+
const closeModal = () => {
|
|
945
|
+
d3.select("#drawer").classed("is-open", false);
|
|
946
|
+
cleanupDemographicChart?.();
|
|
947
|
+
cleanupDemographicChart = null;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
// event listeners
|
|
951
|
+
d3.select("#drawer .button-close").on("click", closeModal);
|
|
952
|
+
|
|
953
|
+
d3.selectAll(".button-quotes").on("click", function () {
|
|
954
|
+
currentDrawerId = d3.select(this).attr("data-id"); // matches opinion.fullID
|
|
955
|
+
updateQuotesDrawer();
|
|
956
|
+
cleanupDemographicChart?.();
|
|
957
|
+
cleanupDemographicChart = createQuotesDrawerParticipantChart();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// keyup event listener for escape key to close modal
|
|
961
|
+
document.addEventListener("keyup", (event) => {
|
|
962
|
+
if (event.key === "Escape") {
|
|
963
|
+
const drawerSel = d3.select("#drawer");
|
|
964
|
+
if (drawerSel.classed("is-open")) closeModal();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
d3.selectAll(".drawer-quotes-filters select").on("change", function () {
|
|
969
|
+
const key = d3.select(this).attr("data-key");
|
|
970
|
+
const value = this.value;
|
|
971
|
+
currentDrawerFilters = currentDrawerFilters.filter((f) => f.key !== key);
|
|
972
|
+
if (value) currentDrawerFilters.push({ key, value });
|
|
973
|
+
updateQuotesDrawer();
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Initializes the Filter Compare logic.
|
|
979
|
+
* Binds click events to ".button-filter-compare" to launch the moda.
|
|
980
|
+
*/
|
|
981
|
+
async function createFilterCompare() {
|
|
982
|
+
const GROUP_LABELS = ["A", "B", "C", "D"];
|
|
983
|
+
const demographics = window.PAYLOAD.demographics;
|
|
984
|
+
const modalSel = d3.select("#filter-compare");
|
|
985
|
+
|
|
986
|
+
const closeModal = () => modalSel.classed("is-open", false);
|
|
987
|
+
|
|
988
|
+
const renderGroups = () => {
|
|
989
|
+
const groupsSel = modalSel.select(".filter-compare-groups");
|
|
990
|
+
groupsSel.html("");
|
|
991
|
+
|
|
992
|
+
compareGroups.forEach((group, gi) => {
|
|
993
|
+
const card = groupsSel.append("div").attr("class", "filter-group-card");
|
|
994
|
+
|
|
995
|
+
const header = card.append("div").attr("class", "filter-group-header");
|
|
996
|
+
|
|
997
|
+
const groupPrefix = i18n.filterModal?.groupLabelPrefix || "Group";
|
|
998
|
+
header
|
|
999
|
+
.append("span")
|
|
1000
|
+
.attr("class", "filter-group-label")
|
|
1001
|
+
.text(`${groupPrefix} ${GROUP_LABELS[gi]}`);
|
|
1002
|
+
|
|
1003
|
+
const deleteGroupText = i18n.filterModal?.deleteGroup || "Delete group";
|
|
1004
|
+
header
|
|
1005
|
+
.append("button")
|
|
1006
|
+
.attr(
|
|
1007
|
+
"class",
|
|
1008
|
+
`ghost button-delete-group${compareGroups.length <= 1 ? " is-hidden" : ""}`,
|
|
1009
|
+
)
|
|
1010
|
+
.html(
|
|
1011
|
+
`<img src="svg/trash.svg" alt="" role="presentation"> ${deleteGroupText}`,
|
|
1012
|
+
)
|
|
1013
|
+
.on("click", () => {
|
|
1014
|
+
compareGroups.splice(gi, 1);
|
|
1015
|
+
renderGroups();
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// const summaryText =
|
|
1019
|
+
// group.length === 0
|
|
1020
|
+
// ? "All Participants"
|
|
1021
|
+
// : group.map((f) => `${f.key}: ${f.value}`).join(", ");
|
|
1022
|
+
// card
|
|
1023
|
+
// .append("div")
|
|
1024
|
+
// .attr("class", "filter-group-summary")
|
|
1025
|
+
// .text(summaryText);
|
|
1026
|
+
|
|
1027
|
+
const grid = card.append("div").attr("class", "filter-group-selects");
|
|
1028
|
+
|
|
1029
|
+
demographics.forEach(({ label, values }) => {
|
|
1030
|
+
const currentVal = group.find((f) => f.key === label)?.value || "";
|
|
1031
|
+
const slugLabel = label
|
|
1032
|
+
.toLowerCase()
|
|
1033
|
+
.replace(/\W+/g, "-")
|
|
1034
|
+
.replace(/^-|-$/g, "");
|
|
1035
|
+
const selectId = `filter-group-${gi}-${slugLabel}`;
|
|
1036
|
+
const wrapper = grid
|
|
1037
|
+
.append("div")
|
|
1038
|
+
.attr("class", "filter-select-wrapper");
|
|
1039
|
+
wrapper.append("label").attr("for", selectId).text(label);
|
|
1040
|
+
const sel = wrapper
|
|
1041
|
+
.append("select")
|
|
1042
|
+
.attr("id", selectId)
|
|
1043
|
+
.attr("data-key", label);
|
|
1044
|
+
const anyOptionText = i18n.filterModal?.anyOption || "Any";
|
|
1045
|
+
sel.append("option").attr("value", "").text(anyOptionText);
|
|
1046
|
+
values.forEach(({ value }) => {
|
|
1047
|
+
sel
|
|
1048
|
+
.append("option")
|
|
1049
|
+
.attr("value", value)
|
|
1050
|
+
.text(value)
|
|
1051
|
+
.property("selected", value === currentVal);
|
|
1052
|
+
});
|
|
1053
|
+
sel.on("change", function () {
|
|
1054
|
+
const key = d3.select(this).attr("data-key");
|
|
1055
|
+
const val = this.value;
|
|
1056
|
+
compareGroups[gi] = compareGroups[gi].filter((f) => f.key !== key);
|
|
1057
|
+
if (val) compareGroups[gi].push({ key, value: val });
|
|
1058
|
+
const allParticipantsText =
|
|
1059
|
+
i18n.filterModal?.allParticipants || "All Participants";
|
|
1060
|
+
const updated =
|
|
1061
|
+
compareGroups[gi].length === 0
|
|
1062
|
+
? allParticipantsText
|
|
1063
|
+
: compareGroups[gi].map((f) => `${f.key}: ${f.value}`).join(", ");
|
|
1064
|
+
card.select(".filter-group-summary").text(updated);
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
modalSel
|
|
1070
|
+
.select(".button-add-group")
|
|
1071
|
+
.classed("is-hidden", compareGroups.length >= GROUP_LABELS.length);
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
renderGroups();
|
|
1075
|
+
|
|
1076
|
+
d3.select("#filter-compare .button-close").on("click", closeModal);
|
|
1077
|
+
d3.select("#filter-compare .filter-compare-bg").on("click", closeModal);
|
|
1078
|
+
|
|
1079
|
+
d3.selectAll(".button-filter-compare").on("click", () => {
|
|
1080
|
+
modalSel.classed("is-open", true);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
d3.select("#filter-compare .button-add-group").on("click", () => {
|
|
1084
|
+
if (compareGroups.length < GROUP_LABELS.length) {
|
|
1085
|
+
compareGroups.push([]);
|
|
1086
|
+
renderGroups();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
d3.select("#filter-compare .button-reset-groups").on("click", () => {
|
|
1091
|
+
compareGroups = [[]];
|
|
1092
|
+
renderGroups();
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
d3.select("#filter-compare .button-apply-groups").on("click", () => {
|
|
1096
|
+
renderOverviewChart();
|
|
1097
|
+
closeModal();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
document.addEventListener("keyup", (event) => {
|
|
1101
|
+
if (event.key === "Escape" && modalSel.classed("is-open")) closeModal();
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Initializes Share functionality (Copy Link).
|
|
1107
|
+
* Generates deep links using the element's ID.
|
|
1108
|
+
*/
|
|
1109
|
+
function createShare() {
|
|
1110
|
+
const shareSel = d3.select("#share");
|
|
1111
|
+
shareSel.select(".button-close-share").on("click", () => {
|
|
1112
|
+
shareSel.classed("is-open", false);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// copy button
|
|
1116
|
+
shareSel.select(".button-copy").on("click", function () {
|
|
1117
|
+
const input = shareSel.select(".share-url input").node();
|
|
1118
|
+
const inputText = input.value;
|
|
1119
|
+
navigator.clipboard
|
|
1120
|
+
.writeText(inputText)
|
|
1121
|
+
.then(() => {
|
|
1122
|
+
console.log("copied");
|
|
1123
|
+
})
|
|
1124
|
+
.catch((err) => {
|
|
1125
|
+
console.error("Failed to copy text: ", err);
|
|
1126
|
+
});
|
|
1127
|
+
d3.select(this).classed("is-copied", true);
|
|
1128
|
+
setTimeout(() => {
|
|
1129
|
+
d3.select(this).classed("is-copied", false);
|
|
1130
|
+
}, 2000);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
d3.selectAll(".button-share").on("click", function () {
|
|
1134
|
+
const id = d3.select(this).attr("data-id");
|
|
1135
|
+
const text = d3.select(this).attr("data-text");
|
|
1136
|
+
const url = `${window.location.origin}${window.location.pathname}#${id}`;
|
|
1137
|
+
shareSel.select(".share-url input").property("value", url);
|
|
1138
|
+
shareSel.select(".share-title span").text(text);
|
|
1139
|
+
shareSel.classed("is-open", true);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Initializes the sidebar menu toggle.
|
|
1145
|
+
*/
|
|
1146
|
+
function createMenu() {
|
|
1147
|
+
const sidebar = d3.select(".sidebar");
|
|
1148
|
+
const button = d3.select("header .button-menu");
|
|
1149
|
+
|
|
1150
|
+
button.on("click", () => {
|
|
1151
|
+
const isOpen = sidebar.classed("is-open");
|
|
1152
|
+
sidebar.classed("is-open", !isOpen);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
d3.selectAll(".sidebar a").on("click", () => {
|
|
1156
|
+
sidebar.classed("is-open", false);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Initializes print listeners.
|
|
1162
|
+
* Ensures `<details>` elements are expanded when printing and collapsed after.
|
|
1163
|
+
*/
|
|
1164
|
+
function createPrint() {
|
|
1165
|
+
window.addEventListener("beforeprint", () => {
|
|
1166
|
+
document.querySelectorAll("details:not([open])").forEach((el) => {
|
|
1167
|
+
el.setAttribute("open", "");
|
|
1168
|
+
el.dataset.wasClosed = "true";
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
window.addEventListener("afterprint", () => {
|
|
1173
|
+
document.querySelectorAll("details[data-was-closed]").forEach((el) => {
|
|
1174
|
+
el.removeAttribute("open");
|
|
1175
|
+
delete el.dataset.wasClosed;
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Initializes toggle inputs to switch between Topic and Opinion views.
|
|
1182
|
+
*/
|
|
1183
|
+
function createToggle() {
|
|
1184
|
+
const toggleInputs = document.querySelectorAll('input[name="toggleView"]');
|
|
1185
|
+
toggleInputs.forEach((input) => {
|
|
1186
|
+
input.addEventListener("change", (e) => {
|
|
1187
|
+
activeOverviewChart = e.target.value;
|
|
1188
|
+
renderOverviewChart();
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Initializes the Full Report / Predicted Agreement content toggle.
|
|
1195
|
+
*/
|
|
1196
|
+
function createContentToggle() {
|
|
1197
|
+
const btns = document.querySelectorAll(".content-toggle-btn");
|
|
1198
|
+
btns.forEach((btn) => {
|
|
1199
|
+
btn.addEventListener("click", () => {
|
|
1200
|
+
const target = btn.dataset.target;
|
|
1201
|
+
|
|
1202
|
+
btns.forEach((b) => d3.select(b).classed("is-active", false));
|
|
1203
|
+
d3.select(btn).classed("is-active", true);
|
|
1204
|
+
document
|
|
1205
|
+
.querySelectorAll("#full-report, #predicted-agreement")
|
|
1206
|
+
.forEach((el) => {
|
|
1207
|
+
d3.select(el).classed("is-visible", el.id === target);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
d3.selectAll(".sidebar-inner").classed("is-active", false);
|
|
1211
|
+
d3.select(`.sidebar-inner--${target}`).classed("is-active", true);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Initializes the Quotes / Demographics content toggle.
|
|
1218
|
+
*/
|
|
1219
|
+
function createDrawerToggle() {
|
|
1220
|
+
const btns = document.querySelectorAll(".drawer-toggle-inner button");
|
|
1221
|
+
btns.forEach((btn) => {
|
|
1222
|
+
btn.addEventListener("click", () => {
|
|
1223
|
+
const target = btn.dataset.target;
|
|
1224
|
+
btns.forEach((b) => d3.select(b).classed("is-active", false));
|
|
1225
|
+
d3.select(btn).classed("is-active", true);
|
|
1226
|
+
document
|
|
1227
|
+
.querySelectorAll("#drawer-quotes, #drawer-demographics")
|
|
1228
|
+
.forEach((el) => {
|
|
1229
|
+
d3.select(el).classed("is-visible", el.id === target);
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Application entry point: initialize all components and render the default view.
|
|
1236
|
+
(() => {
|
|
1237
|
+
if (OVERVIEW_CHART_TYPE === "toggle") createToggle();
|
|
1238
|
+
|
|
1239
|
+
activeOverviewChart =
|
|
1240
|
+
OVERVIEW_CHART_TYPE === "opinions" ? "opinions" : "topics";
|
|
1241
|
+
renderOverviewChart();
|
|
1242
|
+
createParticipantChart();
|
|
1243
|
+
createDonutCharts();
|
|
1244
|
+
createQuotesDrawer();
|
|
1245
|
+
createFilterCompare();
|
|
1246
|
+
createShare();
|
|
1247
|
+
createMenu();
|
|
1248
|
+
createPrint();
|
|
1249
|
+
createContentToggle();
|
|
1250
|
+
createDrawerToggle();
|
|
1251
|
+
})();
|