@cosla/sensemaking-report-builder 0.1.2 → 0.2.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 +7 -5
- package/data.js +81 -30
- package/package.json +4 -2
- package/palette.js +126 -0
- package/schemas/config.v1.json +4 -4
- package/src/index.mustache +6 -0
- package/src/script.js +5 -1
package/README.md
CHANGED
|
@@ -76,7 +76,9 @@ npx @cosla/sensemaking-report-builder config-schema --schema-version 1
|
|
|
76
76
|
Prints a [JSON Schema draft 2020-12](https://json-schema.org/draft/2020-12/schema) document for supported `config.json` keys (`schemas/config.v1.json`), including application field `config_schema_version`. Unknown `--schema-version` values fail with a clear error.
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
npm
|
|
79
|
+
npm test # smoke (config-schema) + unit tests
|
|
80
|
+
npm run smoke # config-schema only
|
|
81
|
+
npm run unit # node:test suite only
|
|
80
82
|
```
|
|
81
83
|
|
|
82
84
|
Logo and translations are configured in `config.json` (not CLI flags). Place `logo.svg` / `logo.png` next to that config (under `--inputDir` when using dir mode).
|
|
@@ -87,7 +89,7 @@ Logo and translations are configured in `config.json` (not CLI flags). Place `lo
|
|
|
87
89
|
|
|
88
90
|
Required columns: `topic`, `opinion`, `quote`, `participant_id`.
|
|
89
91
|
|
|
90
|
-
Optional: `AVERAGE_OF_2_BRIDGING` or `AVERAGE_OF_3_BRIDGING` (used to sort quotes). Demographic columns: prefix with `demo:` (e.g. `demo:Age`).
|
|
92
|
+
Optional: `AVERAGE_OF_2_BRIDGING` or `AVERAGE_OF_3_BRIDGING` (used to sort quotes; OF_2 preferred when both are present). When neither column is present, a warning is printed and quotes keep input order. Demographic columns: prefix with `demo:` (e.g. `demo:Age`). When no demographic columns have values, the participant overview and drawer demographics tab are omitted.
|
|
91
93
|
|
|
92
94
|
### Summary JSON
|
|
93
95
|
|
|
@@ -103,9 +105,9 @@ Same shape as Python `sensemaking-report-text` / `report_data.json`: `text`, `su
|
|
|
103
105
|
| `number_of_top_opinions` | Opinions overview chart size |
|
|
104
106
|
| `number_of_sample_quotes` | Quote previews per opinion |
|
|
105
107
|
| `low_sample_warning_threshold` | Low-sample warning threshold |
|
|
106
|
-
| `
|
|
107
|
-
| `demographic_colors` | Participant chart colours |
|
|
108
|
-
| `excluded_topics` / `excluded_opinions` | Hide named topics/opinions (exact match to CSV columns) |
|
|
108
|
+
| `chart_colors` | Overview chart colours (array of hex strings). Default: `#DA3D2E`, `#F2A50C`, `#80CD57`, `#00885F`, `#11C2CE`, `#1233A6`, `#8A2FE4`, `#F388D2`. Blank entries are ignored; an empty list falls back to the default. |
|
|
109
|
+
| `demographic_colors` | Participant chart colours: a single hex string (a 6-step light-to-dark palette is generated) or an explicit array of colours. Default base: `#4886f7`. Generated ramps use CSS relative colour syntax (`oklch(from …)`; Chrome 119+, Firefox 128+, Safari 18+); use an explicit hex array for older browsers. |
|
|
110
|
+
| `excluded_topics` / `excluded_opinions` | Hide named topics/opinions (exact match to CSV columns). CamelCase aliases `excludedTopics` / `excludedOpinions` are also accepted at runtime. |
|
|
109
111
|
|
|
110
112
|
### Predicted agreement (optional)
|
|
111
113
|
|
package/data.js
CHANGED
|
@@ -10,8 +10,35 @@
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
|
|
13
|
+
import { resolveDemographicColors } from "./palette.js";
|
|
14
|
+
|
|
13
15
|
const demographics_prefix = "demo:";
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Bridging average columns, in preference order. Matches columns written by
|
|
19
|
+
* get_bridging_scores.py (OF_3) and older pipeline outputs (OF_2).
|
|
20
|
+
* @type {string[]}
|
|
21
|
+
*/
|
|
22
|
+
export const BRIDGING_COLUMNS = [
|
|
23
|
+
"AVERAGE_OF_2_BRIDGING",
|
|
24
|
+
"AVERAGE_OF_3_BRIDGING",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default topic / overview chart colours when config.chart_colors is absent.
|
|
29
|
+
* @type {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_CHART_COLORS = [
|
|
32
|
+
"#DA3D2E",
|
|
33
|
+
"#F2A50C",
|
|
34
|
+
"#80CD57",
|
|
35
|
+
"#00885F",
|
|
36
|
+
"#11C2CE",
|
|
37
|
+
"#1233A6",
|
|
38
|
+
"#8A2FE4",
|
|
39
|
+
"#F388D2",
|
|
40
|
+
];
|
|
41
|
+
|
|
15
42
|
/**
|
|
16
43
|
* @typedef {Object} RawOpinion
|
|
17
44
|
* @property {string} topic - The high-level topic category.
|
|
@@ -22,6 +49,39 @@ const demographics_prefix = "demo:";
|
|
|
22
49
|
* @property {string|number} [AVERAGE_OF_3_BRIDGING] - Alternate bridging column.
|
|
23
50
|
*/
|
|
24
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Returns whether any row has a known bridging-score column.
|
|
54
|
+
* @param {Object[]} rows
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function hasBridgingColumn(rows) {
|
|
58
|
+
return rows.some((row) => BRIDGING_COLUMNS.some((col) => col in row));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
|
|
63
|
+
* Prefers OF_2 when both are present (legacy Cosla behaviour).
|
|
64
|
+
* @param {RawOpinion|Object} row
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
export function getBridgingScore(row) {
|
|
68
|
+
const num = Number(row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING);
|
|
69
|
+
return Number.isFinite(num) ? num : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Drops blank/non-string entries from a colour list. Non-arrays pass through.
|
|
74
|
+
* Empty or all-blank arrays become undefined so callers can fall back to
|
|
75
|
+
* defaults (guards against Consul string-list rows that submit `[""]`).
|
|
76
|
+
* @param {unknown} value
|
|
77
|
+
* @returns {unknown}
|
|
78
|
+
*/
|
|
79
|
+
export function cleanColorList(value) {
|
|
80
|
+
if (!Array.isArray(value)) return value;
|
|
81
|
+
const cleaned = value.filter((c) => typeof c === "string" && c.trim() !== "");
|
|
82
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
25
85
|
/**
|
|
26
86
|
* @typedef {Object} BuildOptions
|
|
27
87
|
* @property {"inline"|"static"|"config-schema"} command
|
|
@@ -315,16 +375,6 @@ function generateId(str, useFirstWords = false) {
|
|
|
315
375
|
);
|
|
316
376
|
}
|
|
317
377
|
|
|
318
|
-
/**
|
|
319
|
-
* Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
|
|
320
|
-
* @param {RawOpinion} row
|
|
321
|
-
* @returns {number}
|
|
322
|
-
*/
|
|
323
|
-
function bridgingScore(row) {
|
|
324
|
-
const raw = row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING;
|
|
325
|
-
return raw ? +raw : 0;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
378
|
/**
|
|
329
379
|
* Resolves optional user i18n file under the staged input directory.
|
|
330
380
|
* @param {Object} config
|
|
@@ -369,6 +419,13 @@ export function processReportData({
|
|
|
369
419
|
index,
|
|
370
420
|
}));
|
|
371
421
|
|
|
422
|
+
if (opinions.length > 0 && !hasBridgingColumn(opinions)) {
|
|
423
|
+
console.warn(
|
|
424
|
+
`Warning: no ${BRIDGING_COLUMNS.join(" or ")} column found in the ` +
|
|
425
|
+
"opinions data; quotes will not be ordered by bridging score.",
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
372
429
|
const config = configPath
|
|
373
430
|
? JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
|
374
431
|
: {};
|
|
@@ -422,8 +479,12 @@ export function processReportData({
|
|
|
422
479
|
}
|
|
423
480
|
|
|
424
481
|
const overviewChart = config.overview_chart || "toggle";
|
|
425
|
-
const excludedTopics = new Set(
|
|
426
|
-
|
|
482
|
+
const excludedTopics = new Set(
|
|
483
|
+
config.excluded_topics ?? config.excludedTopics ?? [],
|
|
484
|
+
);
|
|
485
|
+
const excludedOpinions = new Set(
|
|
486
|
+
config.excluded_opinions ?? config.excludedOpinions ?? [],
|
|
487
|
+
);
|
|
427
488
|
const reportOptions = {
|
|
428
489
|
logo: config.logo || "",
|
|
429
490
|
overviewChart,
|
|
@@ -437,23 +498,11 @@ export function processReportData({
|
|
|
437
498
|
Math.max(config.number_of_top_opinions || 10, 2),
|
|
438
499
|
20,
|
|
439
500
|
),
|
|
440
|
-
topicColors:
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
"#00897B",
|
|
446
|
-
"#EFB22F",
|
|
447
|
-
"#aaa",
|
|
448
|
-
],
|
|
449
|
-
demographicColors: config.demographic_colors || [
|
|
450
|
-
"#4886f7",
|
|
451
|
-
"#4071d5",
|
|
452
|
-
"#385db3",
|
|
453
|
-
"#2f4a93",
|
|
454
|
-
"#273874",
|
|
455
|
-
"#1e2656",
|
|
456
|
-
],
|
|
501
|
+
topicColors:
|
|
502
|
+
cleanColorList(config.chart_colors) ?? DEFAULT_CHART_COLORS,
|
|
503
|
+
demographicColors: resolveDemographicColors(
|
|
504
|
+
cleanColorList(config.demographic_colors),
|
|
505
|
+
),
|
|
457
506
|
};
|
|
458
507
|
|
|
459
508
|
/**
|
|
@@ -472,7 +521,7 @@ export function processReportData({
|
|
|
472
521
|
index: v.index,
|
|
473
522
|
text: v.quote,
|
|
474
523
|
participant_id: v.participant_id,
|
|
475
|
-
avg_bridging:
|
|
524
|
+
avg_bridging: getBridgingScore(v),
|
|
476
525
|
...demos,
|
|
477
526
|
};
|
|
478
527
|
})
|
|
@@ -710,6 +759,7 @@ export function processReportData({
|
|
|
710
759
|
});
|
|
711
760
|
|
|
712
761
|
demographics.sort((a, b) => a.label.localeCompare(b.label, i18n.locale));
|
|
762
|
+
const hasDemographics = demographics.some((d) => d.values.length > 0);
|
|
713
763
|
|
|
714
764
|
const executiveSummary = parseSummary(cleanMarkdown(summary.text || ""));
|
|
715
765
|
const title = stripMarkdownHeader(summary.title);
|
|
@@ -740,6 +790,7 @@ export function processReportData({
|
|
|
740
790
|
propositionsGenerated,
|
|
741
791
|
topics,
|
|
742
792
|
demographics,
|
|
793
|
+
hasDemographics,
|
|
743
794
|
predicted,
|
|
744
795
|
hasPredicted: predicted.topics.length > 0,
|
|
745
796
|
i18n,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosla/sensemaking-report-builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Interactive HTML report builder for Cosla Python sensemaking pipeline outputs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build.js",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"bin/",
|
|
12
12
|
"build.js",
|
|
13
13
|
"data.js",
|
|
14
|
+
"palette.js",
|
|
14
15
|
"src/**/*",
|
|
15
16
|
"schemas/**/*",
|
|
16
17
|
"README.md",
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
"preview": "node build.js preview",
|
|
26
27
|
"github": "node build.js github",
|
|
27
28
|
"smoke": "node smoke.js",
|
|
28
|
-
"
|
|
29
|
+
"unit": "node --test",
|
|
30
|
+
"test": "npm run smoke && npm run unit"
|
|
29
31
|
},
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|
package/palette.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sequential color palette generation for the report UI.
|
|
3
|
+
*
|
|
4
|
+
* Builds a light-to-dark palette from a single base hex color using CSS
|
|
5
|
+
* Relative Color Syntax (`oklch(from <color> l c h)`), delegating color-space
|
|
6
|
+
* conversion and gamut mapping to the browser's CSS engine.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Number of colors generated for the demographic chart. The chart shows at
|
|
11
|
+
* most five values plus "Other" (see data.js), so six colors are enough.
|
|
12
|
+
* @type {number}
|
|
13
|
+
*/
|
|
14
|
+
export const DEMOGRAPHIC_PALETTE_STEPS = 6;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base color for the default demographic palette.
|
|
18
|
+
* @type {string}
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_DEMOGRAPHIC_BASE_COLOR = "#4886f7";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Lightest OKLCH lightness used in generated palettes.
|
|
24
|
+
*
|
|
25
|
+
* Palettes deliberately use a wide lightness range: lightness differences are
|
|
26
|
+
* what keep segments distinguishable for color-blind readers. The lightest
|
|
27
|
+
* segment is therefore a tint with less than 3:1 contrast against the chart
|
|
28
|
+
* background; segments are separated by gaps and labelled in the legend, so
|
|
29
|
+
* color is not the only way to tell values apart.
|
|
30
|
+
* @type {number}
|
|
31
|
+
*/
|
|
32
|
+
export const PALETTE_MAX_LIGHTNESS = 0.85;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Darkest OKLCH lightness used in generated palettes. Dark enough for a strong
|
|
36
|
+
* end to the ramp while staying visibly tinted rather than black.
|
|
37
|
+
* @type {number}
|
|
38
|
+
*/
|
|
39
|
+
export const PALETTE_MIN_LIGHTNESS = 0.22;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fraction of the base color's chroma kept at PALETTE_MAX_LIGHTNESS. Steps
|
|
43
|
+
* lighter than the base taper towards this, so pale steps are soft tints
|
|
44
|
+
* rather than fluorescent.
|
|
45
|
+
* @type {number}
|
|
46
|
+
*/
|
|
47
|
+
export const LIGHTEST_CHROMA_FRACTION = 0.5;
|
|
48
|
+
|
|
49
|
+
const HEX_COLOR_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns whether `value` is a `#rgb` or `#rrggbb` hex color string.
|
|
53
|
+
* @param {unknown} value
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function isHexColor(value) {
|
|
57
|
+
return typeof value === "string" && HEX_COLOR_RE.test(value.trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generates a light-to-dark palette of CSS `oklch(from ...)` strings from a
|
|
62
|
+
* single base hex color.
|
|
63
|
+
*
|
|
64
|
+
* Every step keeps the base hue (`h`), spaces lightness (`targetL`) evenly
|
|
65
|
+
* from PALETTE_MAX_LIGHTNESS down to PALETTE_MIN_LIGHTNESS, and scales the
|
|
66
|
+
* base chroma (`c`) in CSS:
|
|
67
|
+
* - Steps darker than the base scale chroma proportionally (`targetL / l`).
|
|
68
|
+
* - Steps lighter than the base taper chroma linearly toward
|
|
69
|
+
* `LIGHTEST_CHROMA_FRACTION * c` at `PALETTE_MAX_LIGHTNESS`.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} [baseHex=DEFAULT_DEMOGRAPHIC_BASE_COLOR] - `#rgb` or `#rrggbb`.
|
|
72
|
+
* @param {number} [steps=DEMOGRAPHIC_PALETTE_STEPS] - Number of steps (>= 2).
|
|
73
|
+
* @returns {string[]} CSS `oklch(from ...)` color strings, lightest to darkest.
|
|
74
|
+
* @throws {Error} If `baseHex` is not a valid hex color or `steps` < 2.
|
|
75
|
+
*/
|
|
76
|
+
export function generateSequentialPalette(
|
|
77
|
+
baseHex = DEFAULT_DEMOGRAPHIC_BASE_COLOR,
|
|
78
|
+
steps = DEMOGRAPHIC_PALETTE_STEPS,
|
|
79
|
+
) {
|
|
80
|
+
if (!isHexColor(baseHex)) {
|
|
81
|
+
throw new Error(`Invalid hex color: ${baseHex}`);
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isInteger(steps) || steps < 2) {
|
|
84
|
+
throw new Error(`steps must be an integer >= 2, got ${steps}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const base = baseHex.trim().toLowerCase();
|
|
88
|
+
const lightTaper = +(1 - LIGHTEST_CHROMA_FRACTION).toFixed(4);
|
|
89
|
+
|
|
90
|
+
return Array.from({ length: steps }, (_, i) => {
|
|
91
|
+
const t = i / (steps - 1);
|
|
92
|
+
const targetL = +(
|
|
93
|
+
PALETTE_MAX_LIGHTNESS -
|
|
94
|
+
t * (PALETTE_MAX_LIGHTNESS - PALETTE_MIN_LIGHTNESS)
|
|
95
|
+
).toFixed(4);
|
|
96
|
+
|
|
97
|
+
const darkScale = `${targetL} / max(l, 0.001)`;
|
|
98
|
+
const lightProgress = `clamp(0, (${targetL} - l) / max(0.001, ${PALETTE_MAX_LIGHTNESS} - l), 1)`;
|
|
99
|
+
const lightScale = `1 - ${lightTaper} * ${lightProgress}`;
|
|
100
|
+
|
|
101
|
+
return `oklch(from ${base} ${targetL} calc(c * min(${darkScale}, ${lightScale})) h)`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolves the `demographic_colors` config value into a palette.
|
|
107
|
+
*
|
|
108
|
+
* Accepts an explicit array of colors (used as-is), a single hex color (a
|
|
109
|
+
* palette is generated from it), or nothing (a palette is generated from
|
|
110
|
+
* DEFAULT_DEMOGRAPHIC_BASE_COLOR).
|
|
111
|
+
*
|
|
112
|
+
* @param {string|string[]|undefined} configValue
|
|
113
|
+
* @returns {string[]}
|
|
114
|
+
*/
|
|
115
|
+
export function resolveDemographicColors(configValue) {
|
|
116
|
+
if (Array.isArray(configValue)) return configValue;
|
|
117
|
+
if (isHexColor(configValue)) return generateSequentialPalette(configValue);
|
|
118
|
+
if (configValue != null && configValue !== "") {
|
|
119
|
+
console.warn(
|
|
120
|
+
`Warning: demographic_colors must be an array or a hex color such as ` +
|
|
121
|
+
`"${DEFAULT_DEMOGRAPHIC_BASE_COLOR}"; got ` +
|
|
122
|
+
`${JSON.stringify(configValue)}. Using default colors.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return generateSequentialPalette(DEFAULT_DEMOGRAPHIC_BASE_COLOR);
|
|
126
|
+
}
|
package/schemas/config.v1.json
CHANGED
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
"properties": {
|
|
8
8
|
"title": {
|
|
9
9
|
"type": "string",
|
|
10
|
-
"title": "Report
|
|
10
|
+
"title": "Report title"
|
|
11
11
|
},
|
|
12
12
|
"logo": {
|
|
13
13
|
"type": "string",
|
|
14
|
-
"title": "Logo
|
|
14
|
+
"title": "Logo image filename"
|
|
15
15
|
},
|
|
16
16
|
"excluded_topics": {
|
|
17
17
|
"type": "array",
|
|
18
|
-
"title": "Excluded
|
|
18
|
+
"title": "Excluded topics",
|
|
19
19
|
"description": "Topic names to omit (exact match)",
|
|
20
20
|
"items": {
|
|
21
21
|
"type": "string"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"excluded_opinions": {
|
|
25
25
|
"type": "array",
|
|
26
|
-
"title": "Excluded
|
|
26
|
+
"title": "Excluded opinions",
|
|
27
27
|
"description": "Opinion names to omit (exact match)",
|
|
28
28
|
"items": {
|
|
29
29
|
"type": "string"
|
package/src/index.mustache
CHANGED
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
{{/executiveSummary}}
|
|
96
96
|
</section>
|
|
97
97
|
</article>
|
|
98
|
+
{{#hasDemographics}}
|
|
98
99
|
<article>
|
|
99
100
|
<section id="participant-overview">
|
|
100
101
|
<header>
|
|
@@ -105,6 +106,7 @@
|
|
|
105
106
|
<div id="participant-overview-chart" class="demographic-chart"></div>
|
|
106
107
|
</section>
|
|
107
108
|
</article>
|
|
109
|
+
{{/hasDemographics}}
|
|
108
110
|
<article>
|
|
109
111
|
<section id="conversation-overview">
|
|
110
112
|
<header>
|
|
@@ -245,9 +247,11 @@
|
|
|
245
247
|
<button class="is-active" data-target="drawer-quotes">
|
|
246
248
|
{{i18n.drawer.participantQuotesTitle}} (<span class="drawer-quote-count"></span>)
|
|
247
249
|
</button>
|
|
250
|
+
{{#hasDemographics}}
|
|
248
251
|
<button data-target="drawer-demographics">
|
|
249
252
|
{{i18n.drawer.participantDemographics}}
|
|
250
253
|
</button>
|
|
254
|
+
{{/hasDemographics}}
|
|
251
255
|
</div>
|
|
252
256
|
</div>
|
|
253
257
|
<div id="drawer-quotes" class="tab is-visible">
|
|
@@ -265,9 +269,11 @@
|
|
|
265
269
|
<ul class="drawer-quotes"></ul>
|
|
266
270
|
</div>
|
|
267
271
|
|
|
272
|
+
{{#hasDemographics}}
|
|
268
273
|
<div id="drawer-demographics" class="tab">
|
|
269
274
|
<div id="drawer-demographics-chart" class="demographic-chart"></div>
|
|
270
275
|
</div>
|
|
276
|
+
{{/hasDemographics}}
|
|
271
277
|
<button class="button-close"><img inline src="svg/x.svg" alt="{{i18n.meta.closeAlt}}"></button>
|
|
272
278
|
</div>
|
|
273
279
|
</div>
|
package/src/script.js
CHANGED
|
@@ -246,7 +246,7 @@ function createDemographicChart({ id, data }) {
|
|
|
246
246
|
return `calc(${ratio * 100}% - ${shrinkPixels}px)`;
|
|
247
247
|
})
|
|
248
248
|
.attr("rx", borderRadius)
|
|
249
|
-
.
|
|
249
|
+
.style(
|
|
250
250
|
"fill",
|
|
251
251
|
(d, i) => DEMOGRAPHIC_PALETTE[i % DEMOGRAPHIC_PALETTE.length],
|
|
252
252
|
);
|
|
@@ -532,6 +532,8 @@ function createParticipantChart() {
|
|
|
532
532
|
/** @type {Demographic[]} */
|
|
533
533
|
const data = window.PAYLOAD.demographics;
|
|
534
534
|
const id = "participant-overview-chart";
|
|
535
|
+
// The section is omitted from the template when there is no demographic data.
|
|
536
|
+
if (!document.getElementById(id)) return;
|
|
535
537
|
createDemographicChart({ id, data });
|
|
536
538
|
}
|
|
537
539
|
|
|
@@ -870,6 +872,8 @@ function updateQuotesDrawer() {
|
|
|
870
872
|
|
|
871
873
|
function createQuotesDrawerParticipantChart() {
|
|
872
874
|
const id = "drawer-demographics-chart";
|
|
875
|
+
// The tab is omitted from the template when there is no demographic data.
|
|
876
|
+
if (!document.getElementById(id)) return null;
|
|
873
877
|
const unknownOpinionText = i18n.drawer?.unknownOpinion || "Unknown Opinion";
|
|
874
878
|
const match = flatOpinions.find((o) => o.fullID === currentDrawerId);
|
|
875
879
|
const text = match?.text || unknownOpinionText;
|