@forwardimpact/pathway 0.25.15 → 0.25.20

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Comparison Radar Chart - displays two overlaid radar charts
3
+ */
4
+
5
+ import {
6
+ escapeHtml,
7
+ wrapLabel,
8
+ getTextAnchor,
9
+ createRadarSvg,
10
+ drawLevelRings,
11
+ drawAxisLines,
12
+ createRadarTooltip,
13
+ } from "./radar-utils.js";
14
+
15
+ export class ComparisonRadarChart {
16
+ /**
17
+ * @param {Object} config
18
+ * @param {HTMLElement} config.container
19
+ * @param {import('./radar.js').RadarDataPoint[]} config.currentData
20
+ * @param {import('./radar.js').RadarDataPoint[]} config.targetData
21
+ * @param {Object} [config.options]
22
+ */
23
+ constructor({ container, currentData, targetData, options = {} }) {
24
+ this.container = container;
25
+ this.currentData = currentData;
26
+ this.targetData = targetData;
27
+ this.options = {
28
+ levels: options.levels || 5,
29
+ currentColor: options.currentColor || "#3b82f6",
30
+ targetColor: options.targetColor || "#10b981",
31
+ showLabels: options.showLabels !== false,
32
+ showTooltips: options.showTooltips !== false,
33
+ size: options.size || 400,
34
+ labelOffset: options.labelOffset || 50,
35
+ };
36
+
37
+ this.center = this.options.size / 2;
38
+ this.radius = this.options.size / 2 - this.options.labelOffset - 20;
39
+ this.angleSlice = (Math.PI * 2) / this.currentData.length;
40
+
41
+ this.svg = null;
42
+ this.tooltip = null;
43
+ }
44
+
45
+ render() {
46
+ this.container.innerHTML = "";
47
+
48
+ this.svg = createRadarSvg(this.options.size, ["comparison-radar-chart"]);
49
+
50
+ const sharedParams = {
51
+ radius: this.radius,
52
+ center: this.center,
53
+ angleSlice: this.angleSlice,
54
+ dataLength: this.currentData.length,
55
+ };
56
+
57
+ drawLevelRings(this.svg, { ...sharedParams, levels: this.options.levels });
58
+ drawAxisLines(this.svg, sharedParams);
59
+
60
+ this.drawDataPolygon(this.targetData, this.options.targetColor, 0.2);
61
+ this.drawDataPolygon(this.currentData, this.options.currentColor, 0.3);
62
+ this.drawDataPoints(this.targetData, this.options.targetColor, "target");
63
+ this.drawDataPoints(this.currentData, this.options.currentColor, "current");
64
+
65
+ if (this.options.showLabels) this.drawLabels();
66
+ if (this.options.showTooltips) {
67
+ this.tooltip = createRadarTooltip(this.container, 250);
68
+ }
69
+
70
+ this.container.appendChild(this.svg);
71
+ }
72
+
73
+ drawDataPolygon(data, color, opacity) {
74
+ const points = data.map((d, i) => {
75
+ const angle = this.angleSlice * i - Math.PI / 2;
76
+ const value = d.value / d.maxValue;
77
+ const r = this.radius * value;
78
+ return {
79
+ x: this.center + r * Math.cos(angle),
80
+ y: this.center + r * Math.sin(angle),
81
+ };
82
+ });
83
+
84
+ const polygon = document.createElementNS(
85
+ "http://www.w3.org/2000/svg",
86
+ "polygon",
87
+ );
88
+ polygon.setAttribute(
89
+ "points",
90
+ points.map((p) => `${p.x},${p.y}`).join(" "),
91
+ );
92
+ polygon.classList.add("radar-data");
93
+ polygon.style.fill = color;
94
+ polygon.style.fillOpacity = String(opacity);
95
+ polygon.style.stroke = color;
96
+ polygon.style.strokeWidth = "2";
97
+
98
+ this.svg.appendChild(polygon);
99
+ }
100
+
101
+ drawDataPoints(data, color, type) {
102
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
103
+ group.classList.add("radar-points", `radar-points-${type}`);
104
+
105
+ data.forEach((d, i) => {
106
+ const angle = this.angleSlice * i - Math.PI / 2;
107
+ const value = d.value / d.maxValue;
108
+ const r = this.radius * value;
109
+ const x = this.center + r * Math.cos(angle);
110
+ const y = this.center + r * Math.sin(angle);
111
+
112
+ const circle = document.createElementNS(
113
+ "http://www.w3.org/2000/svg",
114
+ "circle",
115
+ );
116
+ circle.setAttribute("cx", x);
117
+ circle.setAttribute("cy", y);
118
+ circle.setAttribute("r", 4);
119
+ circle.classList.add("radar-point");
120
+ circle.style.fill = color;
121
+ circle.style.stroke = "#fff";
122
+ circle.style.strokeWidth = "2";
123
+ circle.style.cursor = "pointer";
124
+
125
+ if (this.options.showTooltips) {
126
+ circle.addEventListener("mouseenter", (e) =>
127
+ this.showTooltip(e, d, type),
128
+ );
129
+ circle.addEventListener("mouseleave", () => this.hideTooltip());
130
+ }
131
+
132
+ group.appendChild(circle);
133
+ });
134
+
135
+ this.svg.appendChild(group);
136
+ }
137
+
138
+ drawLabels() {
139
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
140
+ group.classList.add("radar-labels");
141
+
142
+ this.currentData.forEach((d, i) => {
143
+ const targetD = this.targetData[i];
144
+ const angle = this.angleSlice * i - Math.PI / 2;
145
+ const labelRadius = this.radius + this.options.labelOffset;
146
+ const x = this.center + labelRadius * Math.cos(angle);
147
+ const y = this.center + labelRadius * Math.sin(angle);
148
+
149
+ const diff = targetD.value - d.value;
150
+ const hasDiff = diff !== 0;
151
+
152
+ const text = document.createElementNS(
153
+ "http://www.w3.org/2000/svg",
154
+ "text",
155
+ );
156
+ text.setAttribute("x", x);
157
+ text.setAttribute("y", y);
158
+ text.classList.add("radar-label");
159
+ if (hasDiff) text.classList.add("has-change");
160
+ text.style.fontSize = "11px";
161
+ text.style.fill = hasDiff
162
+ ? diff > 0
163
+ ? "#059669"
164
+ : "#dc2626"
165
+ : "#475569";
166
+ text.style.fontWeight = hasDiff ? "600" : "400";
167
+ text.style.textAnchor = getTextAnchor(angle);
168
+ text.style.dominantBaseline = "middle";
169
+
170
+ let labelText = d.label;
171
+ if (hasDiff) {
172
+ labelText += ` (${diff > 0 ? "+" : ""}${diff})`;
173
+ }
174
+
175
+ const lines = wrapLabel(labelText, 15);
176
+ const lineHeight = 13;
177
+ const offsetY = -((lines.length - 1) * lineHeight) / 2;
178
+
179
+ lines.forEach((line, lineIndex) => {
180
+ const tspan = document.createElementNS(
181
+ "http://www.w3.org/2000/svg",
182
+ "tspan",
183
+ );
184
+ tspan.setAttribute("x", x);
185
+ tspan.setAttribute("dy", lineIndex === 0 ? offsetY : lineHeight);
186
+ tspan.textContent = line;
187
+ text.appendChild(tspan);
188
+ });
189
+
190
+ if (this.options.showTooltips) {
191
+ text.style.cursor = "pointer";
192
+ text.addEventListener("mouseenter", (e) =>
193
+ this.showComparisonTooltip(e, d, targetD),
194
+ );
195
+ text.addEventListener("mouseleave", () => this.hideTooltip());
196
+ }
197
+
198
+ group.appendChild(text);
199
+ });
200
+
201
+ this.svg.appendChild(group);
202
+ }
203
+
204
+ showTooltip(event, data, type) {
205
+ if (!this.tooltip) return;
206
+
207
+ const rect = this.container.getBoundingClientRect();
208
+ const x = event.clientX - rect.left;
209
+ const y = event.clientY - rect.top;
210
+
211
+ const typeLabel = type === "current" ? "Current" : "Target";
212
+
213
+ this.tooltip.innerHTML = `
214
+ <strong>${escapeHtml(data.label)}</strong><br>
215
+ ${typeLabel}: ${data.value}/${data.maxValue}
216
+ ${data.description ? `<br><small>${escapeHtml(data.description)}</small>` : ""}
217
+ `;
218
+
219
+ this.tooltip.style.left = `${x + 10}px`;
220
+ this.tooltip.style.top = `${y - 10}px`;
221
+ this.tooltip.style.opacity = "1";
222
+ }
223
+
224
+ showComparisonTooltip(event, currentData, targetData) {
225
+ if (!this.tooltip) return;
226
+
227
+ const rect = this.container.getBoundingClientRect();
228
+ const x = event.clientX - rect.left;
229
+ const y = event.clientY - rect.top;
230
+
231
+ const diff = targetData.value - currentData.value;
232
+ const diffText =
233
+ diff > 0
234
+ ? `<span style="color: #10b981">↑ ${diff} level${diff > 1 ? "s" : ""}</span>`
235
+ : diff < 0
236
+ ? `<span style="color: #ef4444">↓ ${Math.abs(diff)} level${Math.abs(diff) > 1 ? "s" : ""}</span>`
237
+ : "<span style='color: #94a3b8'>No change</span>";
238
+
239
+ this.tooltip.innerHTML = `
240
+ <strong>${escapeHtml(currentData.label)}</strong><br>
241
+ Current: ${currentData.value}/${currentData.maxValue}<br>
242
+ Target: ${targetData.value}/${targetData.maxValue}<br>
243
+ ${diffText}
244
+ `;
245
+
246
+ this.tooltip.style.left = `${x + 10}px`;
247
+ this.tooltip.style.top = `${y - 10}px`;
248
+ this.tooltip.style.opacity = "1";
249
+ }
250
+
251
+ hideTooltip() {
252
+ if (this.tooltip) {
253
+ this.tooltip.style.opacity = "0";
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Shared utilities for radar chart visualizations
3
+ */
4
+
5
+ /**
6
+ * Escape HTML special characters to prevent XSS
7
+ * @param {string} text
8
+ * @returns {string}
9
+ */
10
+ export function escapeHtml(text) {
11
+ return text
12
+ .replace(/&/g, "&amp;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;")
16
+ .replace(/'/g, "&#039;");
17
+ }
18
+
19
+ /**
20
+ * Wrap label text into multiple lines
21
+ * @param {string} text
22
+ * @param {number} maxCharsPerLine
23
+ * @returns {string[]}
24
+ */
25
+ export function wrapLabel(text, maxCharsPerLine) {
26
+ if (text.length <= maxCharsPerLine) return [text];
27
+
28
+ const words = text.split(/\s+/);
29
+ const lines = [];
30
+ let currentLine = "";
31
+
32
+ for (const word of words) {
33
+ if (currentLine.length === 0) {
34
+ currentLine = word;
35
+ } else if (currentLine.length + 1 + word.length <= maxCharsPerLine) {
36
+ currentLine += " " + word;
37
+ } else {
38
+ lines.push(currentLine);
39
+ currentLine = word;
40
+ }
41
+ }
42
+
43
+ if (currentLine.length > 0) {
44
+ lines.push(currentLine);
45
+ }
46
+
47
+ return lines.length > 0 ? lines : [text];
48
+ }
49
+
50
+ /**
51
+ * Get text anchor based on angle
52
+ * @param {number} angle
53
+ * @returns {string}
54
+ */
55
+ export function getTextAnchor(angle) {
56
+ const degrees = (angle * 180) / Math.PI + 90;
57
+ if (degrees > 45 && degrees < 135) return "start";
58
+ if (degrees > 225 && degrees < 315) return "end";
59
+ return "middle";
60
+ }
61
+
62
+ /**
63
+ * Create an SVG element with standard radar chart dimensions
64
+ * @param {number} size
65
+ * @param {string[]} [extraClasses]
66
+ * @returns {SVGElement}
67
+ */
68
+ export function createRadarSvg(size, extraClasses = []) {
69
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
70
+ const padding = 40;
71
+ const totalSize = size + padding * 2;
72
+ svg.setAttribute("width", totalSize);
73
+ svg.setAttribute("height", totalSize);
74
+ svg.setAttribute(
75
+ "viewBox",
76
+ `${-padding} ${-padding} ${totalSize} ${totalSize}`,
77
+ );
78
+ svg.classList.add("radar-chart", ...extraClasses);
79
+ return svg;
80
+ }
81
+
82
+ /**
83
+ * Draw concentric level rings on a radar chart
84
+ * @param {SVGElement} svg
85
+ * @param {Object} params
86
+ * @param {number} params.levels
87
+ * @param {number} params.radius
88
+ * @param {number} params.center
89
+ * @param {number} params.angleSlice
90
+ * @param {number} params.dataLength
91
+ */
92
+ export function drawLevelRings(
93
+ svg,
94
+ { levels, radius, center, angleSlice, dataLength },
95
+ ) {
96
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
97
+ group.classList.add("radar-levels");
98
+
99
+ for (let level = 1; level <= levels; level++) {
100
+ const levelRadius = (radius * level) / levels;
101
+ const points = [];
102
+ for (let i = 0; i < dataLength; i++) {
103
+ const angle = angleSlice * i - Math.PI / 2;
104
+ points.push({
105
+ x: center + levelRadius * Math.cos(angle),
106
+ y: center + levelRadius * Math.sin(angle),
107
+ });
108
+ }
109
+
110
+ const polygon = document.createElementNS(
111
+ "http://www.w3.org/2000/svg",
112
+ "polygon",
113
+ );
114
+ polygon.setAttribute(
115
+ "points",
116
+ points.map((p) => `${p.x},${p.y}`).join(" "),
117
+ );
118
+ polygon.classList.add("radar-level");
119
+ polygon.style.fill = "none";
120
+ polygon.style.stroke = "#e2e8f0";
121
+ polygon.style.strokeWidth = "1";
122
+ group.appendChild(polygon);
123
+
124
+ const labelX = center + 5;
125
+ const labelY = center - levelRadius + 4;
126
+ const label = document.createElementNS(
127
+ "http://www.w3.org/2000/svg",
128
+ "text",
129
+ );
130
+ label.setAttribute("x", labelX);
131
+ label.setAttribute("y", labelY);
132
+ label.textContent = level;
133
+ label.classList.add("radar-level-label");
134
+ label.style.fontSize = "10px";
135
+ label.style.fill = "#94a3b8";
136
+ group.appendChild(label);
137
+ }
138
+
139
+ svg.appendChild(group);
140
+ }
141
+
142
+ /**
143
+ * Draw axis lines on a radar chart
144
+ * @param {SVGElement} svg
145
+ * @param {Object} params
146
+ * @param {number} params.radius
147
+ * @param {number} params.center
148
+ * @param {number} params.angleSlice
149
+ * @param {number} params.dataLength
150
+ */
151
+ export function drawAxisLines(svg, { radius, center, angleSlice, dataLength }) {
152
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
153
+ group.classList.add("radar-axes");
154
+
155
+ for (let i = 0; i < dataLength; i++) {
156
+ const angle = angleSlice * i - Math.PI / 2;
157
+ const x = center + radius * Math.cos(angle);
158
+ const y = center + radius * Math.sin(angle);
159
+
160
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
161
+ line.setAttribute("x1", center);
162
+ line.setAttribute("y1", center);
163
+ line.setAttribute("x2", x);
164
+ line.setAttribute("y2", y);
165
+ line.classList.add("radar-axis");
166
+ line.style.stroke = "#cbd5e1";
167
+ line.style.strokeWidth = "1";
168
+ group.appendChild(line);
169
+ }
170
+
171
+ svg.appendChild(group);
172
+ }
173
+
174
+ /**
175
+ * Create a tooltip element for radar charts
176
+ * @param {HTMLElement} container
177
+ * @param {number} [maxWidth=200]
178
+ * @returns {HTMLDivElement}
179
+ */
180
+ export function createRadarTooltip(container, maxWidth = 200) {
181
+ const tooltip = document.createElement("div");
182
+ tooltip.className = "radar-tooltip";
183
+ tooltip.style.cssText = `
184
+ position: absolute;
185
+ background: #1e293b;
186
+ color: white;
187
+ padding: 8px 12px;
188
+ border-radius: 6px;
189
+ font-size: 12px;
190
+ pointer-events: none;
191
+ opacity: 0;
192
+ transition: opacity 0.2s;
193
+ z-index: 100;
194
+ max-width: ${maxWidth}px;
195
+ `;
196
+ container.style.position = "relative";
197
+ container.appendChild(tooltip);
198
+ return tooltip;
199
+ }