@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 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 run smoke # validates config-schema output
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
- | `topic_colors` / `chart_colors` | Overview chart colours |
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(config.excluded_topics || []);
426
- const excludedOpinions = new Set(config.excluded_opinions || []);
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: config.chart_colors || [
441
- "#AFB42B",
442
- "#F4511E",
443
- "#3949AB",
444
- "#E52592",
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: bridgingScore(v),
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.1.2",
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
- "test": "npm run smoke"
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
+ }
@@ -7,15 +7,15 @@
7
7
  "properties": {
8
8
  "title": {
9
9
  "type": "string",
10
- "title": "Report Title Override"
10
+ "title": "Report title"
11
11
  },
12
12
  "logo": {
13
13
  "type": "string",
14
- "title": "Logo Image Filename"
14
+ "title": "Logo image filename"
15
15
  },
16
16
  "excluded_topics": {
17
17
  "type": "array",
18
- "title": "Excluded Topics",
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 Opinions",
26
+ "title": "Excluded opinions",
27
27
  "description": "Opinion names to omit (exact match)",
28
28
  "items": {
29
29
  "type": "string"
@@ -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
- .attr(
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;