diff --git a/dashboard/index.html b/dashboard/index.html
index e5591ca35f..4d51c5700a 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -42,8 +42,8 @@
-
-
+
+
diff --git a/dashboard/index_ar.html b/dashboard/index_ar.html
index f2b76eb4a5..07bce9ee4c 100644
--- a/dashboard/index_ar.html
+++ b/dashboard/index_ar.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_da.html b/dashboard/index_da.html
index e283f7d158..07f3a73970 100644
--- a/dashboard/index_da.html
+++ b/dashboard/index_da.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_de.html b/dashboard/index_de.html
index 730941718e..aaf6a298b2 100644
--- a/dashboard/index_de.html
+++ b/dashboard/index_de.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_es.html b/dashboard/index_es.html
index f4633b54df..fab1dfa8dd 100644
--- a/dashboard/index_es.html
+++ b/dashboard/index_es.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_fi.html b/dashboard/index_fi.html
index 4adee7875c..5808ebe7f2 100644
--- a/dashboard/index_fi.html
+++ b/dashboard/index_fi.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_fr.html b/dashboard/index_fr.html
index 7ec1cab5ee..fbf489f971 100644
--- a/dashboard/index_fr.html
+++ b/dashboard/index_fr.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_he.html b/dashboard/index_he.html
index 7fdfd32d90..086a6624f3 100644
--- a/dashboard/index_he.html
+++ b/dashboard/index_he.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_ja.html b/dashboard/index_ja.html
index 887213f67f..3f25063781 100644
--- a/dashboard/index_ja.html
+++ b/dashboard/index_ja.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_ko.html b/dashboard/index_ko.html
index b630a44ca9..0d98a33caa 100644
--- a/dashboard/index_ko.html
+++ b/dashboard/index_ko.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_nl.html b/dashboard/index_nl.html
index ec5bde32aa..dcfe4c89de 100644
--- a/dashboard/index_nl.html
+++ b/dashboard/index_nl.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_no.html b/dashboard/index_no.html
index 6e39affb4b..b7d9adf8a5 100644
--- a/dashboard/index_no.html
+++ b/dashboard/index_no.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_sv.html b/dashboard/index_sv.html
index 1a7078d378..cf2933c5a3 100644
--- a/dashboard/index_sv.html
+++ b/dashboard/index_sv.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/dashboard/index_zh.html b/dashboard/index_zh.html
index ecce05516b..35fb4d6ada 100644
--- a/dashboard/index_zh.html
+++ b/dashboard/index_zh.html
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/index.html b/index.html
index a31800210e..04a316a62a 100644
--- a/index.html
+++ b/index.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_ar.html b/index_ar.html
index 802e27c44e..8214f42b18 100644
--- a/index_ar.html
+++ b/index_ar.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_da.html b/index_da.html
index 6568760d45..b8c4b5293b 100644
--- a/index_da.html
+++ b/index_da.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_de.html b/index_de.html
index 8c8455875a..dfe0691586 100644
--- a/index_de.html
+++ b/index_de.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_es.html b/index_es.html
index f78b2c1204..084bfa0949 100644
--- a/index_es.html
+++ b/index_es.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_fi.html b/index_fi.html
index c738526a7c..c3fe24e04c 100644
--- a/index_fi.html
+++ b/index_fi.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_fr.html b/index_fr.html
index 5568dce8fa..79046d761f 100644
--- a/index_fr.html
+++ b/index_fr.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_he.html b/index_he.html
index bb51fed6a5..f002f94bc8 100644
--- a/index_he.html
+++ b/index_he.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_ja.html b/index_ja.html
index f124b11d96..e187495587 100644
--- a/index_ja.html
+++ b/index_ja.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_ko.html b/index_ko.html
index af475de997..781f2e531e 100644
--- a/index_ko.html
+++ b/index_ko.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_nl.html b/index_nl.html
index d3cc38e5a4..5e1f9271ce 100644
--- a/index_nl.html
+++ b/index_nl.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_no.html b/index_no.html
index 1b5319828d..11fa6b84f0 100644
--- a/index_no.html
+++ b/index_no.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_sv.html b/index_sv.html
index 8a19f1bae4..a511deafba 100644
--- a/index_sv.html
+++ b/index_sv.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/index_zh.html b/index_zh.html
index b11b5996b1..149941a4b5 100644
--- a/index_zh.html
+++ b/index_zh.html
@@ -35,8 +35,8 @@
-
-
+
+
diff --git a/src/browser/dashboards/coalition-dashboard.ts b/src/browser/dashboards/coalition-dashboard.ts
index 3e526b1069..5b7d15e102 100644
--- a/src/browser/dashboards/coalition-dashboard.ts
+++ b/src/browser/dashboards/coalition-dashboard.ts
@@ -160,8 +160,11 @@ async function fetchCoalitionData(): Promise {
const alignment: Record> = {};
csvData.forEach(row => {
const party1 = row['party1']; const party2 = row['party2']; const alignmentRate = parseFloat(row['alignment_rate']);
+ if (!PARTIES[party1] || !PARTIES[party2]) return;
if (!alignment[party1]) alignment[party1] = {};
alignment[party1][party2] = alignmentRate;
+ if (!alignment[party2]) alignment[party2] = {};
+ alignment[party2][party1] = alignmentRate;
});
dataCache.coalitionAlignment = alignment;
logger.info('Coalition data loaded from CSV');
@@ -264,7 +267,7 @@ function renderCoalitionNetwork(): void {
const alignment = dataCache.coalitionAlignment;
if (alignment && alignment[id]) {
const rates = Object.values(alignment[id]).filter((v): v is number => typeof v === 'number');
- influence = rates.length > 0 ? (rates.reduce((s, v) => s + v, 0) / rates.length) / 10 + 3 : 5;
+ influence = rates.length > 0 ? (rates.reduce((s, v) => s + v, 0) / rates.length) * 10 + 3 : 5;
}
return { id, name: PARTIES[id].name, fullName: PARTIES[id].fullName, color: PARTIES[id].color, influence: Math.max(5, Math.min(15, influence)) };
});
@@ -274,7 +277,10 @@ function renderCoalitionNetwork(): void {
nodes.forEach((source, i) => {
nodes.forEach((target, j) => {
if (i < j) {
- const strength = alignment && alignment[source.id] && alignment[source.id][target.id] ? alignment[source.id][target.id] / 100 : 0.5;
+ const rawStrengthForward = alignment?.[source.id]?.[target.id];
+ const rawStrengthBackward = alignment?.[target.id]?.[source.id];
+ const rawStrength = typeof rawStrengthForward === 'number' ? rawStrengthForward : (typeof rawStrengthBackward === 'number' ? rawStrengthBackward : undefined);
+ const strength = typeof rawStrength === 'number' ? rawStrength : 0.5;
links.push({ source: source.id, target: target.id, strength });
}
});
@@ -348,7 +354,10 @@ function renderAlignmentHeatMap(): void {
const heatMapData: { party1: string; party2: string; alignment: number }[] = [];
partyIds.forEach(party1 => {
partyIds.forEach(party2 => {
- const alignmentVal = party1 === party2 ? 1.0 : ((dataCache.coalitionAlignment?.[party1]?.[party2]) ? dataCache.coalitionAlignment[party1][party2] / 100 : 0.5);
+ const rawAlignmentDirect = dataCache.coalitionAlignment?.[party1]?.[party2];
+ const rawAlignmentReverse = dataCache.coalitionAlignment?.[party2]?.[party1];
+ const alignmentSource = typeof rawAlignmentDirect === 'number' ? rawAlignmentDirect : (typeof rawAlignmentReverse === 'number' ? rawAlignmentReverse : 0.5);
+ const alignmentVal = party1 === party2 ? 1.0 : alignmentSource;
heatMapData.push({ party1, party2, alignment: alignmentVal });
});
});
@@ -540,8 +549,39 @@ function generateMockBehavioralData(): Record {
}
function generateMockDecisionData(): any[] { return []; }
-function generateMockAnomalyData(): AnomalyEntry[] { return []; }
-function generateMockAnnualVotesData(): Record { return {}; }
+
+export function generateMockAnomalyData(): AnomalyEntry[] {
+ // Deterministic fallback data when CIA anomaly data is unavailable
+ const deviations: Record = {
+ 'S': 1.85, 'M': 2.10, 'SD': 3.25, 'V': 1.45,
+ 'MP': 2.70, 'C': 1.30, 'L': 1.95, 'KD': 2.50
+ };
+ return Object.keys(PARTIES).map(party => ({
+ party,
+ date: '2024-06-15',
+ deviation: deviations[party] || 1.50,
+ severity: (deviations[party] || 1.50) > 3 ? 'critical' : (deviations[party] || 1.50) > 2 ? 'major' : 'minor'
+ }));
+}
+
+export function generateMockAnnualVotesData(): Record {
+ // Deterministic fallback data for annual vote trends
+ const data: Record = {};
+ const partyBaselines: Record = {
+ 'S': 50000, 'M': 35000, 'SD': 25000, 'V': 12000,
+ 'MP': 10000, 'C': 12000, 'L': 10000, 'KD': 10000
+ };
+ Object.keys(PARTIES).forEach(party => {
+ data[party] = [];
+ const baseline = partyBaselines[party] || 15000;
+ for (let year = 2002; year <= 2025; year++) {
+ // Deterministic variation: alternates ±10% based on year parity
+ const variation = year % 2 === 0 ? 0.9 : 1.1;
+ data[party].push({ year, votes: Math.round(baseline * variation) });
+ }
+ });
+ return data;
+}
// ============================================================================
// EXPORTED INIT
diff --git a/src/browser/dashboards/party-dashboard.ts b/src/browser/dashboards/party-dashboard.ts
index b9f059e92f..462b2dadd2 100644
--- a/src/browser/dashboards/party-dashboard.ts
+++ b/src/browser/dashboards/party-dashboard.ts
@@ -994,6 +994,14 @@ function createComparisonChart(data: CSVRow[]): void {
addChartKeyboardNav(chart, ctx);
}
+/**
+ * Convert a 0–1 alignment rate to a display percentage (0–100).
+ * Exported for testability so unit tests validate the real conversion logic.
+ */
+export function alignmentRateToPercent(rate: number): number {
+ return Math.round(rate * 100);
+}
+
/**
* Create Coalition Alignment HTML visualization.
* Renders the top-6 coalition pairs as progress bars.
@@ -1026,7 +1034,7 @@ function createCoalitionNetwork(data: CSVRow[]): void {
coalitions.push({
name: `${party1Label} + ${party2Label}`,
- strength: Math.round(rate),
+ strength: alignmentRateToPercent(rate),
parties: [row.party1, row.party2],
likelihood: row.coalition_likelihood ?? 'UNKNOWN',
});
@@ -1105,7 +1113,7 @@ function createMomentumChart(data: CSVRow[]): void {
const momentumData: MomentumDataPoint[] = PARTIES.map((party) => {
// Filter data for this party and get most recent quarter
const partyRows = data.filter(
- (row) => row.party === party && row.momentum,
+ (row) => row.party === party && row.momentum !== undefined && row.momentum !== '',
);
if (partyRows.length > 0) {
diff --git a/tests/coalition-dashboard.test.js b/tests/coalition-dashboard.test.js
index 22f17da0c2..a927441bda 100644
--- a/tests/coalition-dashboard.test.js
+++ b/tests/coalition-dashboard.test.js
@@ -6,6 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { generateMockAnomalyData, generateMockAnnualVotesData } from '../src/browser/dashboards/coalition-dashboard.js';
describe('Coalition Dashboard', () => {
let container;
@@ -343,4 +344,143 @@ describe('Coalition Dashboard', () => {
expect(container.classList.contains('loading')).toBe(false);
});
});
+
+ describe('Alignment Rate Data Processing', () => {
+ it('should use alignment_rate directly as 0-1 scale without dividing by 100', () => {
+ // Real CSV stores pairs alphabetically (e.g., KD,M not M,KD)
+ const alignment = { 'KD': { 'M': 0.84 }, 'MP': { 'S': 0.72 } };
+
+ // Network strength should use raw value (not /100)
+ const strength = alignment['KD']['M'];
+ expect(strength).toBe(0.84);
+ expect(strength).toBeGreaterThan(0.5);
+ expect(strength).toBeLessThanOrEqual(1.0);
+
+ // Heat map should also use raw value
+ const heatMapValue = alignment['MP']['S'];
+ expect(heatMapValue).toBe(0.72);
+ expect(heatMapValue * 100).toBeCloseTo(72); // Display as percentage
+ });
+
+ it('should handle reverse-pair lookups when CSV stores only one direction', () => {
+ // CSV has KD,M but code may look up M,KD — reverse lookup should find it
+ const alignment = { 'KD': { 'M': 0.84 } };
+
+ // Forward lookup: KD -> M (present in CSV)
+ const rawForward = alignment?.['KD']?.['M'];
+ expect(typeof rawForward === 'number').toBe(true);
+ expect(rawForward).toBe(0.84);
+
+ // Reverse lookup: M -> KD (not in CSV — should check reverse)
+ const rawDirect = alignment?.['M']?.['KD'];
+ const rawReverse = alignment?.['KD']?.['M'];
+ const resolved = typeof rawDirect === 'number' ? rawDirect : (typeof rawReverse === 'number' ? rawReverse : 0.5);
+ expect(resolved).toBe(0.84); // Found via reverse lookup
+ });
+
+ it('should NOT divide alignment_rate by 100 (values are already 0-1)', () => {
+ // This test validates the fix: alignment_rate 0.84 should render as 84%, not 0.84%
+ const rawAlignmentRate = 0.84; // From CSV
+
+ // WRONG (old behavior): dividing 0-1 value by 100 gives 0.0084
+ const wrongValue = rawAlignmentRate / 100;
+ expect(wrongValue).toBeLessThan(0.01); // This would be incorrect
+
+ // CORRECT (new behavior): use raw value directly
+ const correctValue = rawAlignmentRate;
+ expect(correctValue).toBeCloseTo(0.84);
+ expect(correctValue * 100).toBeCloseTo(84); // Display as 84%
+ });
+
+ it('should calculate node influence correctly with 0-1 alignment rates', () => {
+ // With alignment rates in 0-1 range, average for same-bloc parties ~0.65-0.84
+ const rates = [0.84, 0.83, 0.78]; // M-KD, M-L, M-C alignment rates
+ const avgRate = rates.reduce((s, v) => s + v, 0) / rates.length; // ~0.817
+ const influence = avgRate * 10 + 3; // ~11.17 (good range for visualization)
+
+ expect(influence).toBeGreaterThan(5);
+ expect(influence).toBeLessThan(15);
+ expect(Math.max(5, Math.min(15, influence))).toBeCloseTo(influence);
+ });
+
+ it('should treat alignment value of 0 as valid, not fall back to 0.5', () => {
+ // An alignment of 0 means zero alignment — it should NOT be treated as missing
+ const alignment = { 'S': { 'SD': 0 } };
+ const rawStrength = alignment?.['S']?.['SD'];
+ const strength = typeof rawStrength === 'number' ? rawStrength : 0.5;
+ expect(strength).toBe(0); // Must be 0, not 0.5
+ });
+
+ it('should fall back to 0.5 only for missing alignment data', () => {
+ const alignment = { 'S': {} };
+ const rawStrength = alignment?.['S']?.['M'];
+ const strength = typeof rawStrength === 'number' ? rawStrength : 0.5;
+ expect(strength).toBe(0.5);
+ });
+
+ it('should filter out non-party rows (e.g., party "-") when building alignment matrix', () => {
+ // CSV contains rows where party1 or party2 is '-' (aggregate/independent)
+ const PARTIES_SET = { 'S': true, 'M': true, 'SD': true, 'V': true, 'MP': true, 'C': true, 'L': true, 'KD': true };
+ const csvRows = [
+ { party1: 'KD', party2: 'M', alignment_rate: '0.84' },
+ { party1: '-', party2: 'SD', alignment_rate: '0.39' },
+ { party1: '-', party2: 'S', alignment_rate: '0.34' },
+ { party1: 'S', party2: 'V', alignment_rate: '0.65' },
+ ];
+
+ const alignment = {};
+ csvRows.forEach(row => {
+ const p1 = row.party1; const p2 = row.party2; const rate = parseFloat(row.alignment_rate);
+ if (!PARTIES_SET[p1] || !PARTIES_SET[p2]) return;
+ if (!alignment[p1]) alignment[p1] = {};
+ alignment[p1][p2] = rate;
+ if (!alignment[p2]) alignment[p2] = {};
+ alignment[p2][p1] = rate;
+ });
+
+ // '-' should not appear as a key in the alignment matrix
+ expect(alignment['-']).toBeUndefined();
+ // Real parties should be stored symmetrically
+ expect(alignment['KD']['M']).toBe(0.84);
+ expect(alignment['M']['KD']).toBe(0.84);
+ expect(alignment['S']['V']).toBe(0.65);
+ // '-' entries should not pollute any party's alignment map
+ expect(alignment['SD']?.['-']).toBeUndefined();
+ expect(alignment['S']?.['-']).toBeUndefined();
+ });
+ });
+
+ describe('Mock Data Quality', () => {
+ it('should generate deterministic non-empty mock anomaly data for all parties', () => {
+ const anomalies = generateMockAnomalyData();
+ // Must always produce exactly 8 entries (one per party)
+ expect(anomalies.length).toBe(8);
+ expect(anomalies[0]).toHaveProperty('party');
+ expect(anomalies[0]).toHaveProperty('deviation');
+ expect(anomalies[0]).toHaveProperty('severity');
+ // Verify deterministic: running again yields same result
+ const anomalies2 = generateMockAnomalyData();
+ expect(anomalies).toEqual(anomalies2);
+ // Verify known values from the real generator
+ const sdEntry = anomalies.find(a => a.party === 'SD');
+ expect(sdEntry.deviation).toBe(3.25);
+ expect(sdEntry.severity).toBe('critical');
+ });
+
+ it('should generate deterministic non-empty mock annual votes data', () => {
+ const data = generateMockAnnualVotesData();
+ expect(Object.keys(data).length).toBe(8);
+ expect(data['S'].length).toBeGreaterThan(0);
+ expect(data['S'][0]).toHaveProperty('year');
+ expect(data['S'][0]).toHaveProperty('votes');
+ // Verify deterministic: running again yields same result
+ const data2 = generateMockAnnualVotesData();
+ expect(data).toEqual(data2);
+ // Verify per-party baselines from the real generator (S=50000, not generic 15000)
+ const sVotes2002 = data['S'].find(v => v.year === 2002);
+ expect(sVotes2002.votes).toBe(Math.round(50000 * 0.9)); // even year → 0.9x
+ const mVotes2003 = data['M'].find(v => v.year === 2003);
+ expect(mVotes2003.votes).toBe(Math.round(35000 * 1.1)); // odd year → 1.1x
+ });
+ });
});
diff --git a/tests/party-dashboard.test.js b/tests/party-dashboard.test.js
index 6c81c2f21f..09779fd860 100644
--- a/tests/party-dashboard.test.js
+++ b/tests/party-dashboard.test.js
@@ -12,6 +12,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
+import { alignmentRateToPercent } from '../src/browser/dashboards/party-dashboard.js';
describe('Party Dashboard', () => {
let container;
@@ -361,4 +362,60 @@ describe('Party Dashboard', () => {
expect(containers.length).toBe(1);
});
});
+
+ describe('Coalition Alignment Rate Processing', () => {
+ it('should convert 0-1 alignment_rate to percentage for display using real helper', () => {
+ // Uses the actual exported alignmentRateToPercent() from party-dashboard.ts
+ expect(alignmentRateToPercent(0.84)).toBe(84);
+ expect(alignmentRateToPercent(0.72)).toBe(72);
+ expect(alignmentRateToPercent(0.53)).toBe(53);
+ expect(alignmentRateToPercent(0.35)).toBe(35);
+ });
+
+ it('should NOT show alignment as 1% when rate is 0.84', () => {
+ // Bug fix: Math.round(0.84) = 1, but alignmentRateToPercent(0.84) = 84
+ const rate = 0.84;
+ const wrongResult = Math.round(rate); // This was the old bug
+ const correctResult = alignmentRateToPercent(rate);
+
+ expect(wrongResult).toBe(1); // Old buggy result
+ expect(correctResult).toBe(84); // Correct result from real implementation
+ });
+
+ it('should handle various alignment rates correctly via real helper', () => {
+ const testCases = [
+ { rate: 0.84, expected: 84 },
+ { rate: 0.72, expected: 72 },
+ { rate: 0.53, expected: 53 },
+ { rate: 0.35, expected: 35 },
+ { rate: 0, expected: 0 },
+ { rate: 1, expected: 100 },
+ ];
+ testCases.forEach(({ rate, expected }) => {
+ expect(alignmentRateToPercent(rate)).toBe(expected);
+ });
+ });
+
+ it('should handle momentum filter with zero values', () => {
+ // Bug fix: filter should not reject rows where momentum is 0
+ // CSV parsing produces string values, so test with strings to match runtime types
+ const rows = [
+ { party: 'S', momentum: '0.00', year: '2026', quarter: '1' },
+ { party: 'M', momentum: '0.50', year: '2026', quarter: '1' },
+ { party: 'V', momentum: '', year: '2026', quarter: '1' },
+ ];
+
+ // Old filter: row.momentum (rejects '0.00' falsy? No, '0.00' is truthy as a string)
+ // But numeric 0 from parseFloat would be falsy
+ const numericRows = rows.map(r => ({ ...r, momentumNum: r.momentum !== '' ? parseFloat(r.momentum) : undefined }));
+
+ // Old filter with numeric: row.momentumNum (rejects 0)
+ const oldFiltered = numericRows.filter(r => r.momentumNum);
+ expect(oldFiltered.length).toBe(1); // Only M (0.50)
+
+ // New filter: row.momentum !== undefined && row.momentum !== ''
+ const newFiltered = rows.filter(r => r.momentum !== undefined && r.momentum !== '');
+ expect(newFiltered.length).toBe(2); // S ('0.00') and M ('0.50')
+ });
+ });
});