@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/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
+ })();