@cosla/sensemaking-report-builder 0.1.1 → 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 +22 -5
- package/bin/cli.js +1 -0
- package/build.js +13 -1
- package/data.js +137 -44
- package/package.json +9 -3
- package/palette.js +126 -0
- package/schemas/config.v1.json +33 -0
- package/smoke.js +62 -0
- package/src/index.mustache +6 -0
- package/src/script.js +5 -1
package/README.md
CHANGED
|
@@ -55,7 +55,9 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
|
|
|
55
55
|
|
|
56
56
|
| Arg / flag | Purpose | Default |
|
|
57
57
|
|------------|---------|---------|
|
|
58
|
-
| `inline` \| `static` | Build mode (**required**) | — |
|
|
58
|
+
| `inline` \| `static` | Build mode (**required** for builds) | — |
|
|
59
|
+
| `config-schema` | Print config JSON Schema to stdout (no inputs) | — |
|
|
60
|
+
| `--schema-version` | With `config-schema`: schema version to print | latest (`1`) |
|
|
59
61
|
| `--inputDir` | Base dir for default input paths | `./input` |
|
|
60
62
|
| `--opinions` / `--bridging_scores` | Opinions / bridging scores CSV | `<inputDir>/opinions.csv` |
|
|
61
63
|
| `--summary` | Summary JSON (`report_data.json`) | `<inputDir>/summary.json` |
|
|
@@ -64,6 +66,21 @@ Defaults: opinions `<inputDir>/opinions.csv`, summary `<inputDir>/summary.json`,
|
|
|
64
66
|
| `--output` | **inline only:** path to HTML file | `./output/report.html` |
|
|
65
67
|
| `--outputDir` | **static only:** output directory | `./output` |
|
|
66
68
|
|
|
69
|
+
### Config schema
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx @cosla/sensemaking-report-builder config-schema
|
|
73
|
+
npx @cosla/sensemaking-report-builder config-schema --schema-version 1
|
|
74
|
+
```
|
|
75
|
+
|
|
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
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm test # smoke (config-schema) + unit tests
|
|
80
|
+
npm run smoke # config-schema only
|
|
81
|
+
npm run unit # node:test suite only
|
|
82
|
+
```
|
|
83
|
+
|
|
67
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).
|
|
68
85
|
|
|
69
86
|
## Input contracts
|
|
@@ -72,7 +89,7 @@ Logo and translations are configured in `config.json` (not CLI flags). Place `lo
|
|
|
72
89
|
|
|
73
90
|
Required columns: `topic`, `opinion`, `quote`, `participant_id`.
|
|
74
91
|
|
|
75
|
-
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.
|
|
76
93
|
|
|
77
94
|
### Summary JSON
|
|
78
95
|
|
|
@@ -88,9 +105,9 @@ Same shape as Python `sensemaking-report-text` / `report_data.json`: `text`, `su
|
|
|
88
105
|
| `number_of_top_opinions` | Opinions overview chart size |
|
|
89
106
|
| `number_of_sample_quotes` | Quote previews per opinion |
|
|
90
107
|
| `low_sample_warning_threshold` | Low-sample warning threshold |
|
|
91
|
-
| `
|
|
92
|
-
| `demographic_colors` | Participant chart colours |
|
|
93
|
-
| `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. |
|
|
94
111
|
|
|
95
112
|
### Predicted agreement (optional)
|
|
96
113
|
|
package/bin/cli.js
CHANGED
package/build.js
CHANGED
|
@@ -10,7 +10,11 @@ import { execSync } from "node:child_process";
|
|
|
10
10
|
import csv from "csvtojson";
|
|
11
11
|
import mustache from "mustache";
|
|
12
12
|
import { inlineSource } from "inline-source";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
loadConfigSchema,
|
|
15
|
+
processReportData,
|
|
16
|
+
resolveBuildOptions,
|
|
17
|
+
} from "./data.js";
|
|
14
18
|
|
|
15
19
|
const WORK_DIR_NAME = ".py-report-ui-work";
|
|
16
20
|
|
|
@@ -173,6 +177,13 @@ function inlineFontDataUris(html, fontsDir) {
|
|
|
173
177
|
export async function runBuild(argv = process.argv, cwd = process.cwd()) {
|
|
174
178
|
const options = resolveBuildOptions(argv, cwd);
|
|
175
179
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
180
|
+
|
|
181
|
+
if (options.command === "config-schema") {
|
|
182
|
+
const schema = loadConfigSchema(options.schemaVersion, packageRoot);
|
|
183
|
+
process.stdout.write(`${JSON.stringify(schema, null, 2)}\n`);
|
|
184
|
+
return { command: "config-schema" };
|
|
185
|
+
}
|
|
186
|
+
|
|
176
187
|
const artefactRoot =
|
|
177
188
|
options.command === "inline"
|
|
178
189
|
? path.dirname(options.output)
|
|
@@ -296,6 +307,7 @@ if (isMain) {
|
|
|
296
307
|
} else {
|
|
297
308
|
runBuild(process.argv, process.cwd())
|
|
298
309
|
.then((result) => {
|
|
310
|
+
if (result.command === "config-schema") return;
|
|
299
311
|
console.log(
|
|
300
312
|
`Standalone report generated: ${result.output || result.outputDir}`,
|
|
301
313
|
);
|
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,19 +49,84 @@ 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
|
-
* @property {"inline"|"static"} command
|
|
28
|
-
* @property {string}
|
|
29
|
-
* @property {string
|
|
30
|
-
* @property {string|null}
|
|
31
|
-
* @property {string}
|
|
32
|
-
* @property {string}
|
|
33
|
-
* @property {string
|
|
34
|
-
* @property {string|null}
|
|
35
|
-
* @property {
|
|
36
|
-
* @property {boolean}
|
|
87
|
+
* @property {"inline"|"static"|"config-schema"} command
|
|
88
|
+
* @property {string|null} [schemaVersion]
|
|
89
|
+
* @property {string} [inputDir]
|
|
90
|
+
* @property {string|null} [output]
|
|
91
|
+
* @property {string|null} [outputDir]
|
|
92
|
+
* @property {string} [opinionsPath]
|
|
93
|
+
* @property {string} [summaryPath]
|
|
94
|
+
* @property {string|null} [predictedPath]
|
|
95
|
+
* @property {string|null} [configPath]
|
|
96
|
+
* @property {boolean} [predictedExplicit]
|
|
97
|
+
* @property {boolean} [configExplicit]
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/** Latest shipped config schema version (omit `--schema-version` → this). */
|
|
101
|
+
export const LATEST_CONFIG_SCHEMA_VERSION = 1;
|
|
102
|
+
|
|
103
|
+
const CONFIG_SCHEMA_FILES = {
|
|
104
|
+
1: "config.v1.json",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Loads a config JSON Schema document for the given version.
|
|
109
|
+
* @param {string|number|null|undefined} schemaVersion
|
|
110
|
+
* @param {string} packageRoot Absolute path to the package root (directory of build.js).
|
|
111
|
+
* @returns {object}
|
|
37
112
|
*/
|
|
113
|
+
export function loadConfigSchema(schemaVersion, packageRoot) {
|
|
114
|
+
const version =
|
|
115
|
+
schemaVersion === undefined || schemaVersion === null || schemaVersion === ""
|
|
116
|
+
? LATEST_CONFIG_SCHEMA_VERSION
|
|
117
|
+
: Number(schemaVersion);
|
|
118
|
+
if (!Number.isInteger(version) || !CONFIG_SCHEMA_FILES[version]) {
|
|
119
|
+
const supported = Object.keys(CONFIG_SCHEMA_FILES).join(", ");
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Unknown config schema version "${schemaVersion}". Supported: ${supported}.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const schemaPath = path.join(packageRoot, "schemas", CONFIG_SCHEMA_FILES[version]);
|
|
125
|
+
if (!fs.existsSync(schemaPath)) {
|
|
126
|
+
throw new Error(`Config schema file not found: ${schemaPath}`);
|
|
127
|
+
}
|
|
128
|
+
return JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
|
129
|
+
}
|
|
38
130
|
|
|
39
131
|
/**
|
|
40
132
|
* Parses argv flags into a Map of key -> value.
|
|
@@ -69,16 +161,26 @@ export function resolveBuildOptions(argv = process.argv, cwd = process.cwd()) {
|
|
|
69
161
|
const commandToken = args[0] && !args[0].startsWith("--") ? args[0] : null;
|
|
70
162
|
if (!commandToken) {
|
|
71
163
|
throw new Error(
|
|
72
|
-
'
|
|
164
|
+
'Command is required. Use "inline", "static", or "config-schema" as the first argument.',
|
|
73
165
|
);
|
|
74
166
|
}
|
|
167
|
+
|
|
168
|
+
const flags = parseFlags(args);
|
|
169
|
+
|
|
170
|
+
if (commandToken === "config-schema") {
|
|
171
|
+
return {
|
|
172
|
+
command: commandToken,
|
|
173
|
+
schemaVersion: flags.has("schema-version")
|
|
174
|
+
? flags.get("schema-version")
|
|
175
|
+
: null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
75
179
|
if (commandToken !== "inline" && commandToken !== "static") {
|
|
76
180
|
throw new Error(
|
|
77
|
-
`Unsupported command "${commandToken}". Use "inline" or "
|
|
181
|
+
`Unsupported command "${commandToken}". Use "inline", "static", or "config-schema".`,
|
|
78
182
|
);
|
|
79
183
|
}
|
|
80
|
-
|
|
81
|
-
const flags = parseFlags(args);
|
|
82
184
|
const hasOutput = flags.has("output");
|
|
83
185
|
const hasOutputDir = flags.has("outputDir");
|
|
84
186
|
|
|
@@ -273,16 +375,6 @@ function generateId(str, useFirstWords = false) {
|
|
|
273
375
|
);
|
|
274
376
|
}
|
|
275
377
|
|
|
276
|
-
/**
|
|
277
|
-
* Reads bridging score from AVERAGE_OF_2_BRIDGING or AVERAGE_OF_3_BRIDGING.
|
|
278
|
-
* @param {RawOpinion} row
|
|
279
|
-
* @returns {number}
|
|
280
|
-
*/
|
|
281
|
-
function bridgingScore(row) {
|
|
282
|
-
const raw = row.AVERAGE_OF_2_BRIDGING ?? row.AVERAGE_OF_3_BRIDGING;
|
|
283
|
-
return raw ? +raw : 0;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
378
|
/**
|
|
287
379
|
* Resolves optional user i18n file under the staged input directory.
|
|
288
380
|
* @param {Object} config
|
|
@@ -327,6 +419,13 @@ export function processReportData({
|
|
|
327
419
|
index,
|
|
328
420
|
}));
|
|
329
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
|
+
|
|
330
429
|
const config = configPath
|
|
331
430
|
? JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
|
332
431
|
: {};
|
|
@@ -380,8 +479,12 @@ export function processReportData({
|
|
|
380
479
|
}
|
|
381
480
|
|
|
382
481
|
const overviewChart = config.overview_chart || "toggle";
|
|
383
|
-
const excludedTopics = new Set(
|
|
384
|
-
|
|
482
|
+
const excludedTopics = new Set(
|
|
483
|
+
config.excluded_topics ?? config.excludedTopics ?? [],
|
|
484
|
+
);
|
|
485
|
+
const excludedOpinions = new Set(
|
|
486
|
+
config.excluded_opinions ?? config.excludedOpinions ?? [],
|
|
487
|
+
);
|
|
385
488
|
const reportOptions = {
|
|
386
489
|
logo: config.logo || "",
|
|
387
490
|
overviewChart,
|
|
@@ -395,23 +498,11 @@ export function processReportData({
|
|
|
395
498
|
Math.max(config.number_of_top_opinions || 10, 2),
|
|
396
499
|
20,
|
|
397
500
|
),
|
|
398
|
-
topicColors:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
"#00897B",
|
|
404
|
-
"#EFB22F",
|
|
405
|
-
"#aaa",
|
|
406
|
-
],
|
|
407
|
-
demographicColors: config.demographic_colors || [
|
|
408
|
-
"#4886f7",
|
|
409
|
-
"#4071d5",
|
|
410
|
-
"#385db3",
|
|
411
|
-
"#2f4a93",
|
|
412
|
-
"#273874",
|
|
413
|
-
"#1e2656",
|
|
414
|
-
],
|
|
501
|
+
topicColors:
|
|
502
|
+
cleanColorList(config.chart_colors) ?? DEFAULT_CHART_COLORS,
|
|
503
|
+
demographicColors: resolveDemographicColors(
|
|
504
|
+
cleanColorList(config.demographic_colors),
|
|
505
|
+
),
|
|
415
506
|
};
|
|
416
507
|
|
|
417
508
|
/**
|
|
@@ -430,7 +521,7 @@ export function processReportData({
|
|
|
430
521
|
index: v.index,
|
|
431
522
|
text: v.quote,
|
|
432
523
|
participant_id: v.participant_id,
|
|
433
|
-
avg_bridging:
|
|
524
|
+
avg_bridging: getBridgingScore(v),
|
|
434
525
|
...demos,
|
|
435
526
|
};
|
|
436
527
|
})
|
|
@@ -668,6 +759,7 @@ export function processReportData({
|
|
|
668
759
|
});
|
|
669
760
|
|
|
670
761
|
demographics.sort((a, b) => a.label.localeCompare(b.label, i18n.locale));
|
|
762
|
+
const hasDemographics = demographics.some((d) => d.values.length > 0);
|
|
671
763
|
|
|
672
764
|
const executiveSummary = parseSummary(cleanMarkdown(summary.text || ""));
|
|
673
765
|
const title = stripMarkdownHeader(summary.title);
|
|
@@ -698,6 +790,7 @@ export function processReportData({
|
|
|
698
790
|
propositionsGenerated,
|
|
699
791
|
topics,
|
|
700
792
|
demographics,
|
|
793
|
+
hasDemographics,
|
|
701
794
|
predicted,
|
|
702
795
|
hasPredicted: predicted.topics.length > 0,
|
|
703
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,8 +11,11 @@
|
|
|
11
11
|
"bin/",
|
|
12
12
|
"build.js",
|
|
13
13
|
"data.js",
|
|
14
|
+
"palette.js",
|
|
14
15
|
"src/**/*",
|
|
15
|
-
"
|
|
16
|
+
"schemas/**/*",
|
|
17
|
+
"README.md",
|
|
18
|
+
"smoke.js"
|
|
16
19
|
],
|
|
17
20
|
"scripts": {
|
|
18
21
|
"predev": "node bin/cli.js static --outputDir ./output",
|
|
@@ -21,7 +24,10 @@
|
|
|
21
24
|
"inline": "node bin/cli.js inline",
|
|
22
25
|
"build": "node bin/cli.js inline",
|
|
23
26
|
"preview": "node build.js preview",
|
|
24
|
-
"github": "node build.js github"
|
|
27
|
+
"github": "node build.js github",
|
|
28
|
+
"smoke": "node smoke.js",
|
|
29
|
+
"unit": "node --test",
|
|
30
|
+
"test": "npm run smoke && npm run unit"
|
|
25
31
|
},
|
|
26
32
|
"repository": {
|
|
27
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"config_schema_version": 1,
|
|
4
|
+
"title": "PythonReportConfig",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": true,
|
|
7
|
+
"properties": {
|
|
8
|
+
"title": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"title": "Report title"
|
|
11
|
+
},
|
|
12
|
+
"logo": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"title": "Logo image filename"
|
|
15
|
+
},
|
|
16
|
+
"excluded_topics": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"title": "Excluded topics",
|
|
19
|
+
"description": "Topic names to omit (exact match)",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"excluded_opinions": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"title": "Excluded opinions",
|
|
27
|
+
"description": "Opinion names to omit (exact match)",
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/smoke.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { loadConfigSchema, LATEST_CONFIG_SCHEMA_VERSION } from "./data.js";
|
|
2
|
+
import { runBuild } from "./build.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
function assert(condition, message) {
|
|
9
|
+
if (!condition) {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const schema = loadConfigSchema(null, packageRoot);
|
|
16
|
+
assert(
|
|
17
|
+
schema.config_schema_version === LATEST_CONFIG_SCHEMA_VERSION,
|
|
18
|
+
`Expected config_schema_version ${LATEST_CONFIG_SCHEMA_VERSION}`,
|
|
19
|
+
);
|
|
20
|
+
for (const key of ["title", "logo", "excluded_topics", "excluded_opinions"]) {
|
|
21
|
+
assert(schema.properties?.[key], `Config schema missing property "${key}"`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const explicit = loadConfigSchema(1, packageRoot);
|
|
25
|
+
assert(explicit.config_schema_version === 1, "Expected schema version 1");
|
|
26
|
+
|
|
27
|
+
let failed = false;
|
|
28
|
+
try {
|
|
29
|
+
loadConfigSchema(99, packageRoot);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
failed = true;
|
|
32
|
+
assert(
|
|
33
|
+
String(error.message).includes("Unknown config schema version"),
|
|
34
|
+
`Expected clear unknown-version error, got: ${error.message}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
assert(failed, "Expected unknown schema version to fail.");
|
|
38
|
+
|
|
39
|
+
const previousWrite = process.stdout.write.bind(process.stdout);
|
|
40
|
+
let captured = "";
|
|
41
|
+
process.stdout.write = (chunk) => {
|
|
42
|
+
captured += String(chunk);
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
await runBuild(["node", "build.js", "config-schema"], packageRoot);
|
|
47
|
+
} finally {
|
|
48
|
+
process.stdout.write = previousWrite;
|
|
49
|
+
}
|
|
50
|
+
const printed = JSON.parse(captured);
|
|
51
|
+
assert(
|
|
52
|
+
printed.config_schema_version === 1,
|
|
53
|
+
"config-schema command did not print v1 schema",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
console.log("Smoke tests passed.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
main().catch((error) => {
|
|
60
|
+
console.error(error.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
});
|
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;
|