@jjlmoya/utils-civic 1.1.0 → 1.3.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/package.json +1 -1
- package/src/category/i18n/de.ts +12 -9
- package/src/category/i18n/en.ts +12 -9
- package/src/category/i18n/es.ts +12 -9
- package/src/category/i18n/fr.ts +12 -9
- package/src/category/i18n/id.ts +12 -9
- package/src/category/i18n/it.ts +12 -9
- package/src/category/i18n/ja.ts +12 -9
- package/src/category/i18n/ko.ts +12 -9
- package/src/category/i18n/nl.ts +12 -9
- package/src/category/i18n/pl.ts +12 -9
- package/src/category/i18n/pt.ts +12 -9
- package/src/category/i18n/ru.ts +12 -9
- package/src/category/i18n/sv.ts +12 -9
- package/src/category/i18n/tr.ts +12 -9
- package/src/category/i18n/zh.ts +12 -8
- package/src/category/index.ts +3 -1
- package/src/entries.ts +6 -0
- package/src/index.ts +1 -0
- package/src/tests/i18n_coverage.test.ts +8 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/coalition-majority-calculator/bibliography.astro +6 -0
- package/src/tool/coalition-majority-calculator/bibliography.ts +12 -0
- package/src/tool/coalition-majority-calculator/coalition-majority-calculator.css +467 -0
- package/src/tool/coalition-majority-calculator/component.astro +62 -0
- package/src/tool/coalition-majority-calculator/contract.test.ts +14 -0
- package/src/tool/coalition-majority-calculator/controller.ts +89 -0
- package/src/tool/coalition-majority-calculator/dom-views.ts +145 -0
- package/src/tool/coalition-majority-calculator/entry.ts +28 -0
- package/src/tool/coalition-majority-calculator/evaluator.ts +13 -0
- package/src/tool/coalition-majority-calculator/i18n/de.ts +51 -0
- package/src/tool/coalition-majority-calculator/i18n/en.ts +74 -0
- package/src/tool/coalition-majority-calculator/i18n/es.ts +51 -0
- package/src/tool/coalition-majority-calculator/i18n/fr.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/id.ts +45 -0
- package/src/tool/coalition-majority-calculator/i18n/it.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/ja.ts +21 -0
- package/src/tool/coalition-majority-calculator/i18n/ko.ts +21 -0
- package/src/tool/coalition-majority-calculator/i18n/nl.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/pl.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/pt.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/ru.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/sv.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/tr.ts +12 -0
- package/src/tool/coalition-majority-calculator/i18n/zh.ts +12 -0
- package/src/tool/coalition-majority-calculator/index.ts +11 -0
- package/src/tool/coalition-majority-calculator/logic.test.ts +60 -0
- package/src/tool/coalition-majority-calculator/logic.ts +146 -0
- package/src/tool/coalition-majority-calculator/seo.astro +12 -0
- package/src/tool/coalition-majority-calculator/storage.ts +32 -0
- package/src/tool/coalition-majority-calculator/ui.ts +80 -0
- package/src/tool/vote-seat-disproportionality-analyzer/bibliography.astro +6 -0
- package/src/tool/vote-seat-disproportionality-analyzer/bibliography.ts +16 -0
- package/src/tool/vote-seat-disproportionality-analyzer/component.astro +48 -0
- package/src/tool/vote-seat-disproportionality-analyzer/controller.ts +165 -0
- package/src/tool/vote-seat-disproportionality-analyzer/dom-views.ts +123 -0
- package/src/tool/vote-seat-disproportionality-analyzer/entry.ts +29 -0
- package/src/tool/vote-seat-disproportionality-analyzer/evaluator.ts +15 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/de.ts +44 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/en.ts +110 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/es.ts +42 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/fr.ts +37 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/id.ts +15 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/it.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/ja.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/ko.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/nl.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/pl.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/pt.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/ru.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/sv.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/tr.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/i18n/zh.ts +10 -0
- package/src/tool/vote-seat-disproportionality-analyzer/index.ts +11 -0
- package/src/tool/vote-seat-disproportionality-analyzer/logic.test.ts +61 -0
- package/src/tool/vote-seat-disproportionality-analyzer/logic.ts +119 -0
- package/src/tool/vote-seat-disproportionality-analyzer/seo.astro +14 -0
- package/src/tool/vote-seat-disproportionality-analyzer/storage.ts +28 -0
- package/src/tool/vote-seat-disproportionality-analyzer/ui.ts +70 -0
- package/src/tool/vote-seat-disproportionality-analyzer/vote-seat-disproportionality-analyzer.css +467 -0
- package/src/tools.ts +4 -0
- package/src/types.ts +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export interface PartyInput {
|
|
2
|
+
name: string;
|
|
3
|
+
seats: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CoalitionRecord {
|
|
7
|
+
mask: number;
|
|
8
|
+
members: string[];
|
|
9
|
+
seats: number;
|
|
10
|
+
surplus: number;
|
|
11
|
+
minimal: boolean;
|
|
12
|
+
redundantMembers: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CoalitionAnalysis {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
parties: PartyInput[];
|
|
19
|
+
totalSeats: number;
|
|
20
|
+
threshold: number;
|
|
21
|
+
totalCoalitions: number;
|
|
22
|
+
winningCoalitions: number;
|
|
23
|
+
minimalWinningCoalitions: number;
|
|
24
|
+
pivotTotal: number;
|
|
25
|
+
displayedCoalitions: CoalitionRecord[];
|
|
26
|
+
pivotCounts: Record<string, number>;
|
|
27
|
+
pivotShares: Record<string, number>;
|
|
28
|
+
warnings: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PreparedInput {
|
|
32
|
+
parties: PartyInput[];
|
|
33
|
+
threshold: number;
|
|
34
|
+
totalSeats: number;
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface EnumerationState {
|
|
39
|
+
winning: number;
|
|
40
|
+
minimal: number;
|
|
41
|
+
rows: CoalitionRecord[];
|
|
42
|
+
pivotCounts: Record<string, number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CoalitionInspection {
|
|
46
|
+
record: CoalitionRecord;
|
|
47
|
+
criticalIndexes: number[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAX_PARTIES = 20;
|
|
51
|
+
const DISPLAY_LIMIT = 120;
|
|
52
|
+
|
|
53
|
+
function normalizeName(name: string, index: number, seen: Map<string, number>): string {
|
|
54
|
+
const base = name.trim() || `Party ${index + 1}`;
|
|
55
|
+
const count = seen.get(base) ?? 0;
|
|
56
|
+
seen.set(base, count + 1);
|
|
57
|
+
return count === 0 ? base : `${base} ${count + 1}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function prepareInput(inputs: PartyInput[], threshold: number): PreparedInput | string {
|
|
61
|
+
if (inputs.length === 0) return 'Add at least one party.';
|
|
62
|
+
if (inputs.length > MAX_PARTIES) return `The model supports at most ${MAX_PARTIES} parties.`;
|
|
63
|
+
const seen = new Map<string, number>();
|
|
64
|
+
const parties = inputs.map((party, index) => ({ name: normalizeName(party.name, index, seen), seats: party.seats }));
|
|
65
|
+
if (parties.some((party) => !Number.isSafeInteger(party.seats) || party.seats < 0)) return 'Seats must be whole numbers greater than or equal to zero.';
|
|
66
|
+
if (!Number.isSafeInteger(threshold) || threshold < 1) return 'The threshold must be a whole number above zero.';
|
|
67
|
+
const totalSeats = parties.reduce((sum, party) => sum + party.seats, 0);
|
|
68
|
+
const warnings = inputs.some((party, index) => party.name.trim() !== parties[index]?.name)
|
|
69
|
+
? ['Duplicate or blank names were made unique for the calculation.']
|
|
70
|
+
: [];
|
|
71
|
+
if (threshold > totalSeats) warnings.push('The threshold is above the chamber total, so no coalition can win.');
|
|
72
|
+
return { parties, threshold, totalSeats, warnings };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildSeatTotals(parties: PartyInput[]): Float64Array {
|
|
76
|
+
const totals = new Float64Array(2 ** parties.length);
|
|
77
|
+
for (let mask = 1; mask < totals.length; mask += 1) {
|
|
78
|
+
const bit = mask & -mask;
|
|
79
|
+
const index = Math.log2(bit);
|
|
80
|
+
totals[mask] = (totals[mask ^ bit] ?? 0) + (parties[index]?.seats ?? 0);
|
|
81
|
+
}
|
|
82
|
+
return totals;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function inspectCoalition(mask: number, parties: PartyInput[], seats: number, threshold: number): CoalitionInspection {
|
|
86
|
+
const members: string[] = [];
|
|
87
|
+
const memberIndexes: number[] = [];
|
|
88
|
+
const criticalIndexes: number[] = [];
|
|
89
|
+
for (let index = 0; index < parties.length; index += 1) {
|
|
90
|
+
if ((mask & (1 << index)) === 0) continue;
|
|
91
|
+
memberIndexes.push(index);
|
|
92
|
+
members.push(parties[index]?.name ?? '');
|
|
93
|
+
if (seats - (parties[index]?.seats ?? 0) < threshold) criticalIndexes.push(index);
|
|
94
|
+
}
|
|
95
|
+
const criticalSet = new Set(criticalIndexes);
|
|
96
|
+
return {
|
|
97
|
+
record: { mask, members, seats, surplus: seats - threshold, minimal: criticalIndexes.length === members.length, redundantMembers: memberIndexes.filter((index) => !criticalSet.has(index)).map((index) => parties[index]?.name ?? '') },
|
|
98
|
+
criticalIndexes,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createState(parties: PartyInput[]): EnumerationState {
|
|
103
|
+
return { winning: 0, minimal: 0, rows: [], pivotCounts: Object.fromEntries(parties.map((party) => [party.name, 0])) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addRow(rows: CoalitionRecord[], record: CoalitionRecord): void {
|
|
107
|
+
if (rows.length < DISPLAY_LIMIT) rows.push(record);
|
|
108
|
+
else if (record.minimal && rows.some((row) => !row.minimal)) {
|
|
109
|
+
const index = rows.findIndex((row) => !row.minimal);
|
|
110
|
+
if (index >= 0) rows[index] = record;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function enumerate(parties: PartyInput[], threshold: number, totals: Float64Array): EnumerationState {
|
|
115
|
+
const state = createState(parties);
|
|
116
|
+
for (let mask = 1; mask < totals.length; mask += 1) {
|
|
117
|
+
const seats = totals[mask] ?? 0;
|
|
118
|
+
if (seats < threshold) continue;
|
|
119
|
+
const inspection = inspectCoalition(mask, parties, seats, threshold);
|
|
120
|
+
state.winning += 1;
|
|
121
|
+
if (inspection.record.minimal) state.minimal += 1;
|
|
122
|
+
inspection.criticalIndexes.forEach((index) => {
|
|
123
|
+
const name = parties[index]?.name;
|
|
124
|
+
if (name) state.pivotCounts[name] = (state.pivotCounts[name] ?? 0) + 1;
|
|
125
|
+
});
|
|
126
|
+
addRow(state.rows, inspection.record);
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sortRows(rows: CoalitionRecord[]): CoalitionRecord[] {
|
|
132
|
+
return [...rows].sort((left, right) => Number(right.minimal) - Number(left.minimal) || left.surplus - right.surplus || left.members.length - right.members.length || left.mask - right.mask);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createAnalysis(prepared: PreparedInput, state: EnumerationState): CoalitionAnalysis {
|
|
136
|
+
const pivotTotal = Object.values(state.pivotCounts).reduce((sum, count) => sum + count, 0);
|
|
137
|
+
const pivotShares = Object.fromEntries(Object.entries(state.pivotCounts).map(([name, count]) => [name, pivotTotal === 0 ? 0 : count / pivotTotal]));
|
|
138
|
+
return { valid: true, parties: prepared.parties, totalSeats: prepared.totalSeats, threshold: prepared.threshold, totalCoalitions: 2 ** prepared.parties.length - 1, winningCoalitions: state.winning, minimalWinningCoalitions: state.minimal, pivotTotal, displayedCoalitions: sortRows(state.rows), pivotCounts: state.pivotCounts, pivotShares, warnings: prepared.warnings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function analyzeCoalitions(inputs: PartyInput[], threshold: number): CoalitionAnalysis {
|
|
142
|
+
const prepared = prepareInput(inputs, threshold);
|
|
143
|
+
if (typeof prepared === 'string') return { valid: false, error: prepared, parties: inputs, totalSeats: 0, threshold, totalCoalitions: 0, winningCoalitions: 0, minimalWinningCoalitions: 0, pivotTotal: 0, displayedCoalitions: [], pivotCounts: {}, pivotShares: {}, warnings: [] };
|
|
144
|
+
const totals = buildSeatTotals(prepared.parties);
|
|
145
|
+
return createAnalysis(prepared, enumerate(prepared.parties, prepared.threshold, totals));
|
|
146
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { coalitionMajorityCalculator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
|
|
8
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
9
|
+
const content = await coalitionMajorityCalculator.i18n[locale]?.();
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PartyInput } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface CoalitionSavedState {
|
|
4
|
+
parties: PartyInput[];
|
|
5
|
+
threshold: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'jjlmoya-civic-coalition-majority';
|
|
9
|
+
|
|
10
|
+
export function saveCoalitionState(state: CoalitionSavedState): void {
|
|
11
|
+
try {
|
|
12
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
13
|
+
} catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadCoalitionState(): CoalitionSavedState | null {
|
|
17
|
+
try {
|
|
18
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const parsed = JSON.parse(raw) as CoalitionSavedState;
|
|
21
|
+
if (!Array.isArray(parsed.parties) || typeof parsed.threshold !== 'number') return null;
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearCoalitionState(): void {
|
|
29
|
+
try {
|
|
30
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface CoalitionMajorityUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
addParty: string;
|
|
4
|
+
allCoalitions: string;
|
|
5
|
+
calculate: string;
|
|
6
|
+
chamberSeats: string;
|
|
7
|
+
coalitionSceneLabel: string;
|
|
8
|
+
criticalDefections: string;
|
|
9
|
+
emptyState: string;
|
|
10
|
+
explanation: string;
|
|
11
|
+
edgeCasesText: string;
|
|
12
|
+
edgeCasesTitle: string;
|
|
13
|
+
filterCoalitions: string;
|
|
14
|
+
maximumParties: string;
|
|
15
|
+
majorityThreshold: string;
|
|
16
|
+
methodTitle: string;
|
|
17
|
+
methodText: string;
|
|
18
|
+
minimalCoalition: string;
|
|
19
|
+
noPivots: string;
|
|
20
|
+
noMatchingCoalitions: string;
|
|
21
|
+
none: string;
|
|
22
|
+
notDoText: string;
|
|
23
|
+
notDoTitle: string;
|
|
24
|
+
partyName: string;
|
|
25
|
+
partySeats: string;
|
|
26
|
+
partyTable: string;
|
|
27
|
+
pivotal: string;
|
|
28
|
+
pivotalParty: string;
|
|
29
|
+
redundantMembers: string;
|
|
30
|
+
removeParty: string;
|
|
31
|
+
reset: string;
|
|
32
|
+
resultTitle: string;
|
|
33
|
+
seats: string;
|
|
34
|
+
surplus: string;
|
|
35
|
+
surplusCoalition: string;
|
|
36
|
+
threshold: string;
|
|
37
|
+
thresholdHint: string;
|
|
38
|
+
totalCoalitions: string;
|
|
39
|
+
winningCoalitions: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ui: CoalitionMajorityUI = {
|
|
43
|
+
addParty: 'Add party',
|
|
44
|
+
allCoalitions: 'All winning coalitions',
|
|
45
|
+
calculate: 'Calculate coalitions',
|
|
46
|
+
chamberSeats: 'Total seats',
|
|
47
|
+
coalitionSceneLabel: 'Coalition constellation',
|
|
48
|
+
criticalDefections: 'Critical defections',
|
|
49
|
+
emptyState: 'Add at least two parties and enter their seats to see the coalition constellation.',
|
|
50
|
+
explanation: 'This model treats every listed party as willing to join any combination. It measures seat arithmetic, not political compatibility.',
|
|
51
|
+
edgeCasesText: 'Blank or duplicate names are made unique. Zero seat parties can never be critical. Above 20 parties the exact search is stopped.',
|
|
52
|
+
edgeCasesTitle: 'Edge cases and warnings',
|
|
53
|
+
filterCoalitions: 'Show',
|
|
54
|
+
maximumParties: 'Maximum 20 parties because exhaustive enumeration grows as 2 to the power of N.',
|
|
55
|
+
majorityThreshold: 'Majority threshold',
|
|
56
|
+
methodTitle: 'Model applied',
|
|
57
|
+
methodText: 'Every non empty subset is checked. A coalition wins at or above the declared threshold. A party is pivotal when removing it makes a winning coalition lose.',
|
|
58
|
+
minimalCoalition: 'Minimal winning coalition',
|
|
59
|
+
noMatchingCoalitions: 'No coalitions match this filter.',
|
|
60
|
+
noPivots: 'No party is pivotal under this threshold.',
|
|
61
|
+
none: 'None',
|
|
62
|
+
notDoText: 'It does not assess ideology, policy compatibility, bargaining, legitimacy, stability, or whether a government will form.',
|
|
63
|
+
notDoTitle: 'What this tool does not do',
|
|
64
|
+
partyName: 'Party name',
|
|
65
|
+
partySeats: 'Seats',
|
|
66
|
+
partyTable: 'Coalition details',
|
|
67
|
+
pivotal: 'pivotal',
|
|
68
|
+
pivotalParty: 'Pivotal party',
|
|
69
|
+
redundantMembers: 'Redundant members',
|
|
70
|
+
removeParty: 'Remove party',
|
|
71
|
+
reset: 'Reset example',
|
|
72
|
+
resultTitle: 'Coalition map',
|
|
73
|
+
seats: 'seats',
|
|
74
|
+
surplus: 'surplus',
|
|
75
|
+
surplusCoalition: 'Surplus coalition',
|
|
76
|
+
threshold: 'Threshold',
|
|
77
|
+
thresholdHint: 'A coalition wins when its seats meet or exceed this threshold.',
|
|
78
|
+
totalCoalitions: 'Possible non-empty coalitions',
|
|
79
|
+
winningCoalitions: 'Winning coalitions',
|
|
80
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Michael Gallagher, Proportionality, Disproportionality and Electoral Systems',
|
|
6
|
+
url: 'https://www.tcd.ie/Political_Science/about/people/michael_gallagher/ElectoralStudies1991.pdf',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'John Loosemore and Victor J. Hanby, The Theoretical Limits of Maximum Distortion',
|
|
10
|
+
url: 'https://www.cambridge.org/core/services/aop-cambridge-core/content/view/77CE5C85BDDDE59036B7E91D81FBF87E/S000712340000925Xa.pdf/div-class-title-the-theoretical-limits-of-maximum-distortion-some-analytic-expressions-for-electoral-systems-div.pdf',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Universidad de Valladolid, Índices de desproporcionalidad electoral',
|
|
14
|
+
url: 'https://uvadoc.uva.es/bitstream/handle/10324/71724/TFG-E-2023.pdf?sequence=1',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { VoteSeatUI } from './ui';
|
|
3
|
+
import './vote-seat-disproportionality-analyzer.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui: VoteSeatUI;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui } = Astro.props as Props;
|
|
10
|
+
const initialRows = [
|
|
11
|
+
{ name: 'Cedar', votes: 42000, seats: 8 },
|
|
12
|
+
{ name: 'River', votes: 31000, seats: 5 },
|
|
13
|
+
{ name: 'Lantern', votes: 17000, seats: 2 },
|
|
14
|
+
{ name: 'Common', votes: 10000, seats: 1 },
|
|
15
|
+
];
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<div class="vote-seat-tool" data-vote-seat-tool>
|
|
19
|
+
<div class="comparison-surface">
|
|
20
|
+
<form class="input-surface" data-vote-seat-form>
|
|
21
|
+
<section class="input-section" aria-labelledby="vote-seat-input-heading">
|
|
22
|
+
<div class="section-label"><span class="section-glyph">01</span><h2 id="vote-seat-input-heading">{ui.rowsLabel}</h2></div>
|
|
23
|
+
<p class="field-help">{ui.rowsHelp}</p>
|
|
24
|
+
<div class="party-input-list" data-party-list></div>
|
|
25
|
+
<button class="add-row" type="button" data-add-row>+ {ui.addRow}</button>
|
|
26
|
+
</section>
|
|
27
|
+
<div class="form-actions">
|
|
28
|
+
<button class="primary-action" type="submit">{ui.calculate}</button>
|
|
29
|
+
<button class="secondary-action" type="button" data-reset>{ui.reset}</button>
|
|
30
|
+
</div>
|
|
31
|
+
<p class="tool-status" data-status role="status"></p>
|
|
32
|
+
</form>
|
|
33
|
+
<section class="result-surface" aria-live="polite" aria-labelledby="vote-seat-result-heading">
|
|
34
|
+
<div class="result-heading"><span class="section-glyph">02</span><h2 id="vote-seat-result-heading">{ui.resultLabel}</h2></div>
|
|
35
|
+
<p class="model-notice">{ui.modelNotice}</p>
|
|
36
|
+
<div class="empty-result" data-empty-result>{ui.emptyState}</div>
|
|
37
|
+
<div class="result-content" data-result hidden></div>
|
|
38
|
+
</section>
|
|
39
|
+
</div>
|
|
40
|
+
<script is:inline type="application/json" data-vote-seat-config set:html={JSON.stringify({ ui, initialRows })}></script>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
import { mountVoteSeatTool } from './controller';
|
|
45
|
+
|
|
46
|
+
const root = document.querySelector<HTMLElement>('[data-vote-seat-tool]');
|
|
47
|
+
if (root) mountVoteSeatTool(root);
|
|
48
|
+
</script>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { calculateDisproportionality, type VoteSeatInput, type VoteSeatRow } from './logic';
|
|
2
|
+
import { renderDisproportionalityResult } from './dom-views';
|
|
3
|
+
import { evaluateDisproportionality } from './evaluator';
|
|
4
|
+
import { clearVoteSeatInput, loadVoteSeatInput, saveVoteSeatInput } from './storage';
|
|
5
|
+
import type { VoteSeatUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface Config {
|
|
8
|
+
ui: VoteSeatUI;
|
|
9
|
+
initialRows: VoteSeatRow[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface InputSpec {
|
|
13
|
+
label: string;
|
|
14
|
+
field: string;
|
|
15
|
+
value: string;
|
|
16
|
+
type: string;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Elements {
|
|
21
|
+
form: HTMLFormElement;
|
|
22
|
+
list: HTMLElement;
|
|
23
|
+
result: HTMLElement;
|
|
24
|
+
empty: HTMLElement;
|
|
25
|
+
status: HTMLElement;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const readConfig = (root: HTMLElement): Config => {
|
|
29
|
+
const script = root.querySelector<HTMLScriptElement>('[data-vote-seat-config]');
|
|
30
|
+
return JSON.parse(script?.textContent ?? '{}') as Config;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isInput = (value: VoteSeatInput | null): value is VoteSeatInput => Boolean(value?.rows?.length);
|
|
34
|
+
|
|
35
|
+
const renderRows = (list: HTMLElement, rows: VoteSeatRow[], ui: VoteSeatUI): void => {
|
|
36
|
+
list.replaceChildren();
|
|
37
|
+
rows.forEach((row, index) => {
|
|
38
|
+
const wrapper = document.createElement('div');
|
|
39
|
+
wrapper.className = 'party-input-row';
|
|
40
|
+
wrapper.dataset.rowIndex = String(index);
|
|
41
|
+
const number = document.createElement('span');
|
|
42
|
+
number.className = 'row-number';
|
|
43
|
+
number.textContent = String(index + 1).padStart(2, '0');
|
|
44
|
+
const nameLabel = createInputLabel({ label: ui.partyNameLabel, field: 'name', value: row.name, type: 'text', maxLength: 32 });
|
|
45
|
+
const votesLabel = createInputLabel({ label: ui.votesLabel, field: 'votes', value: String(row.votes), type: 'number' });
|
|
46
|
+
const seatsLabel = createInputLabel({ label: ui.seatsLabel, field: 'seats', value: String(row.seats), type: 'number' });
|
|
47
|
+
const remove = document.createElement('button');
|
|
48
|
+
remove.type = 'button';
|
|
49
|
+
remove.className = 'remove-row';
|
|
50
|
+
remove.dataset.removeRow = 'true';
|
|
51
|
+
remove.ariaLabel = ui.removeRow;
|
|
52
|
+
remove.textContent = '×';
|
|
53
|
+
wrapper.append(number, nameLabel, votesLabel, seatsLabel, remove);
|
|
54
|
+
list.append(wrapper);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const createInputLabel = ({ label, field, value, type, maxLength }: InputSpec): HTMLLabelElement => {
|
|
59
|
+
const wrapper = document.createElement('label');
|
|
60
|
+
wrapper.className = `input-label input-${field}`;
|
|
61
|
+
const caption = document.createElement('span');
|
|
62
|
+
caption.textContent = label;
|
|
63
|
+
const input = document.createElement('input');
|
|
64
|
+
input.type = type;
|
|
65
|
+
input.dataset.field = field;
|
|
66
|
+
input.value = value;
|
|
67
|
+
if (maxLength) input.maxLength = maxLength;
|
|
68
|
+
if (type === 'number') {
|
|
69
|
+
input.min = '0';
|
|
70
|
+
input.step = 'any';
|
|
71
|
+
input.inputMode = 'decimal';
|
|
72
|
+
}
|
|
73
|
+
wrapper.append(caption, input);
|
|
74
|
+
return wrapper;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const readRows = (list: HTMLElement): VoteSeatRow[] => Array.from(list.querySelectorAll<HTMLElement>('[data-row-index]')).map((row) => {
|
|
78
|
+
const input = (field: string) => row.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
|
|
79
|
+
return {
|
|
80
|
+
name: input('name')?.value ?? '',
|
|
81
|
+
votes: Number(input('votes')?.value ?? Number.NaN),
|
|
82
|
+
seats: Number(input('seats')?.value ?? Number.NaN),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const setStatus = (status: HTMLElement, tone: string, message: string): void => {
|
|
87
|
+
status.className = `tool-status status-${tone}`;
|
|
88
|
+
status.textContent = message;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const getElements = (root: HTMLElement): Elements | null => {
|
|
92
|
+
const form = root.querySelector<HTMLFormElement>('[data-vote-seat-form]');
|
|
93
|
+
const list = root.querySelector<HTMLElement>('[data-party-list]');
|
|
94
|
+
const result = root.querySelector<HTMLElement>('[data-result]');
|
|
95
|
+
const empty = root.querySelector<HTMLElement>('[data-empty-result]');
|
|
96
|
+
const status = root.querySelector<HTMLElement>('[data-status]');
|
|
97
|
+
return form && list && result && empty && status ? { form, list, result, empty, status } : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const renderError = (elements: Elements, ui: VoteSeatUI, error: unknown): void => {
|
|
101
|
+
elements.result.hidden = true;
|
|
102
|
+
elements.empty.hidden = false;
|
|
103
|
+
const message = error instanceof Error ? error.message : 'Enter valid values.';
|
|
104
|
+
setStatus(elements.status, 'error', `${ui.errorPrefix}: ${message}`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const createUpdater = (config: Config, elements: Elements): (() => void) => {
|
|
108
|
+
const update = (): void => {
|
|
109
|
+
const rows = readRows(elements.list);
|
|
110
|
+
try {
|
|
111
|
+
const calculated = calculateDisproportionality({ rows });
|
|
112
|
+
renderDisproportionalityResult(elements.result, calculated, config.ui, document.documentElement.lang || 'en');
|
|
113
|
+
elements.result.hidden = false;
|
|
114
|
+
elements.empty.hidden = true;
|
|
115
|
+
const state = evaluateDisproportionality(calculated, config.ui);
|
|
116
|
+
setStatus(elements.status, state.tone, state.text);
|
|
117
|
+
saveVoteSeatInput({ rows });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
renderError(elements, config.ui, error);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return update;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const bindRowActions = (elements: Elements, ui: VoteSeatUI, update: () => void): void => {
|
|
126
|
+
elements.list.addEventListener('click', (event) => {
|
|
127
|
+
const target = event.target as HTMLElement;
|
|
128
|
+
if (!target.closest('[data-remove-row]')) return;
|
|
129
|
+
const rows = readRows(elements.list);
|
|
130
|
+
const row = target.closest<HTMLElement>('[data-row-index]');
|
|
131
|
+
const index = Number(row?.dataset.rowIndex ?? -1);
|
|
132
|
+
if (index >= 0 && rows.length > 1) rows.splice(index, 1);
|
|
133
|
+
renderRows(elements.list, rows, ui);
|
|
134
|
+
update();
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const bindFormActions = (root: HTMLElement, config: Config, elements: Elements, update: () => void): void => {
|
|
139
|
+
elements.form.addEventListener('input', update);
|
|
140
|
+
elements.form.addEventListener('submit', (event) => { event.preventDefault(); update(); });
|
|
141
|
+
root.querySelector('[data-add-row]')?.addEventListener('click', () => {
|
|
142
|
+
const rows = readRows(elements.list);
|
|
143
|
+
rows.push({ name: `List ${rows.length + 1}`, votes: 0, seats: 0 });
|
|
144
|
+
renderRows(elements.list, rows, config.ui);
|
|
145
|
+
update();
|
|
146
|
+
});
|
|
147
|
+
root.querySelector('[data-reset]')?.addEventListener('click', () => {
|
|
148
|
+
clearVoteSeatInput();
|
|
149
|
+
renderRows(elements.list, config.initialRows, config.ui);
|
|
150
|
+
update();
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const mountVoteSeatTool = (root: HTMLElement): void => {
|
|
155
|
+
const config = readConfig(root);
|
|
156
|
+
const elements = getElements(root);
|
|
157
|
+
if (!elements) return;
|
|
158
|
+
const stored = loadVoteSeatInput();
|
|
159
|
+
const rows = isInput(stored) ? stored.rows : config.initialRows;
|
|
160
|
+
const update = createUpdater(config, elements);
|
|
161
|
+
renderRows(elements.list, rows, config.ui);
|
|
162
|
+
bindFormActions(root, config, elements, update);
|
|
163
|
+
bindRowActions(elements, config.ui, update);
|
|
164
|
+
update();
|
|
165
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { DisproportionalityResult, PartyDisproportionality } from './logic';
|
|
2
|
+
import type { VoteSeatUI } from './ui';
|
|
3
|
+
|
|
4
|
+
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] => {
|
|
5
|
+
const element = document.createElement(tag);
|
|
6
|
+
if (className) element.className = className;
|
|
7
|
+
return element;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const text = (value: string): Text => document.createTextNode(value);
|
|
11
|
+
|
|
12
|
+
const addMetric = (parent: HTMLElement, label: string, value: string, accent: string): void => {
|
|
13
|
+
const metric = make('div', `metric metric-${accent}`);
|
|
14
|
+
const metricLabel = make('span', 'metric-label');
|
|
15
|
+
metricLabel.append(text(label));
|
|
16
|
+
const metricValue = make('strong', 'metric-value');
|
|
17
|
+
metricValue.append(text(value));
|
|
18
|
+
metric.append(metricLabel, metricValue);
|
|
19
|
+
parent.append(metric);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const formatNumber = (value: number, locale: string, maximumFractionDigits = 2): string => new Intl.NumberFormat(locale, {
|
|
23
|
+
maximumFractionDigits,
|
|
24
|
+
}).format(value);
|
|
25
|
+
|
|
26
|
+
const formatRatio = (value: number | null, locale: string): string => {
|
|
27
|
+
if (value === null) return 'n/a';
|
|
28
|
+
if (value === Infinity) return '∞';
|
|
29
|
+
return formatNumber(value, locale, 3);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const addRibbon = (parent: HTMLElement, party: PartyDisproportionality, ui: VoteSeatUI, locale: string): void => {
|
|
33
|
+
const row = make('div', 'share-strip');
|
|
34
|
+
const name = make('div', 'share-name');
|
|
35
|
+
name.append(text(party.name));
|
|
36
|
+
const shares = make('div', 'share-ribbons');
|
|
37
|
+
const voteRibbon = make('span', 'share-ribbon vote-ribbon');
|
|
38
|
+
voteRibbon.style.width = `${party.voteSharePercent}%`;
|
|
39
|
+
voteRibbon.title = `${ui.voteShareLabel}: ${formatNumber(party.voteSharePercent, locale)}%`;
|
|
40
|
+
const seatRibbon = make('span', 'share-ribbon seat-ribbon');
|
|
41
|
+
seatRibbon.style.width = `${party.seatSharePercent}%`;
|
|
42
|
+
seatRibbon.title = `${ui.seatShareLabel}: ${formatNumber(party.seatSharePercent, locale)}%`;
|
|
43
|
+
shares.append(voteRibbon, seatRibbon);
|
|
44
|
+
const gap = make('span', 'share-gap');
|
|
45
|
+
gap.append(text(`${party.differencePercent > 0 ? '+' : ''}${formatNumber(party.differencePercent, locale)} pp`));
|
|
46
|
+
row.append(name, shares, gap);
|
|
47
|
+
parent.append(row);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const buildScene = (result: DisproportionalityResult, ui: VoteSeatUI, locale: string): HTMLElement => {
|
|
51
|
+
const scene = make('div', 'press-scene');
|
|
52
|
+
const sceneLegend = make('div', 'scene-legend');
|
|
53
|
+
sceneLegend.append(text(`${ui.voteShareLabel} / ${ui.seatShareLabel}`));
|
|
54
|
+
scene.append(sceneLegend);
|
|
55
|
+
result.parties.forEach((party) => addRibbon(scene, party, ui, locale));
|
|
56
|
+
return scene;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const buildHeaders = (ui: VoteSeatUI): HTMLTableRowElement => {
|
|
60
|
+
const row = make('tr');
|
|
61
|
+
[ui.partyNameLabel, ui.voteShareLabel, ui.seatShareLabel, ui.differenceLabel, ui.ratioLabel, ui.directionLabel].forEach((label) => {
|
|
62
|
+
const cell = make('th');
|
|
63
|
+
cell.scope = 'col';
|
|
64
|
+
cell.append(text(label));
|
|
65
|
+
row.append(cell);
|
|
66
|
+
});
|
|
67
|
+
return row;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const buildBody = (result: DisproportionalityResult, ui: VoteSeatUI, locale: string): HTMLTableSectionElement => {
|
|
71
|
+
const body = make('tbody');
|
|
72
|
+
result.parties.forEach((party) => {
|
|
73
|
+
const row = make('tr');
|
|
74
|
+
[
|
|
75
|
+
party.name,
|
|
76
|
+
`${formatNumber(party.voteSharePercent, locale)}%`,
|
|
77
|
+
`${formatNumber(party.seatSharePercent, locale)}%`,
|
|
78
|
+
`${party.differencePercent > 0 ? '+' : ''}${formatNumber(party.differencePercent, locale)} pp`,
|
|
79
|
+
formatRatio(party.voteToSeatRatio, locale),
|
|
80
|
+
ui[party.direction],
|
|
81
|
+
].forEach((value) => {
|
|
82
|
+
const cell = make('td');
|
|
83
|
+
cell.append(text(value));
|
|
84
|
+
row.append(cell);
|
|
85
|
+
});
|
|
86
|
+
body.append(row);
|
|
87
|
+
});
|
|
88
|
+
return body;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const buildDetail = (result: DisproportionalityResult, ui: VoteSeatUI, locale: string): HTMLElement => {
|
|
92
|
+
const section = make('section', 'detail-section');
|
|
93
|
+
const heading = make('h3', 'detail-heading');
|
|
94
|
+
heading.append(text(ui.detailLabel));
|
|
95
|
+
const table = make('table', 'detail-table');
|
|
96
|
+
const head = make('thead');
|
|
97
|
+
head.append(buildHeaders(ui));
|
|
98
|
+
table.append(head, buildBody(result, ui, locale));
|
|
99
|
+
section.append(heading, table);
|
|
100
|
+
return section;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const renderDisproportionalityResult = (element: HTMLElement, result: DisproportionalityResult, ui: VoteSeatUI, locale: string): void => {
|
|
104
|
+
const wrapper = make('div', 'result-stack');
|
|
105
|
+
const metrics = make('div', 'metrics-grid');
|
|
106
|
+
addMetric(metrics, ui.gallagherLabel, formatNumber(result.gallagherIndex, locale, 2), 'gallagher');
|
|
107
|
+
addMetric(metrics, ui.loosemoreHanbyLabel, formatNumber(result.loosemoreHanbyIndex, locale, 2), 'loosemore');
|
|
108
|
+
addMetric(metrics, ui.sainteLagueLabel, formatNumber(result.sainteLagueIndex, locale, 3), 'sainte');
|
|
109
|
+
addMetric(metrics, ui.maxGapLabel, `${formatNumber(result.maxAbsoluteDifferencePercent, locale, 2)} pp`, 'gap');
|
|
110
|
+
const totals = make('p', 'totals-line');
|
|
111
|
+
totals.append(text(`${ui.totalVotesLabel}: ${formatNumber(result.totalVotes, locale)} · ${ui.totalSeatsLabel}: ${formatNumber(result.totalSeats, locale)}`));
|
|
112
|
+
const guide = make('p', 'result-guide');
|
|
113
|
+
guide.append(text(ui.resultGuide));
|
|
114
|
+
const scene = buildScene(result, ui, locale);
|
|
115
|
+
const note = make('p', 'model-note');
|
|
116
|
+
note.append(text(ui.proportionNote));
|
|
117
|
+
const zeroVote = result.sainteLagueRows < result.parties.length ? make('p', 'model-note model-note-caution') : null;
|
|
118
|
+
zeroVote?.append(text(ui.zeroVoteNote));
|
|
119
|
+
wrapper.append(metrics, totals, guide, scene, note);
|
|
120
|
+
if (zeroVote) wrapper.append(zeroVote);
|
|
121
|
+
wrapper.append(buildDetail(result, ui, locale));
|
|
122
|
+
element.replaceChildren(wrapper);
|
|
123
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CivicToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { VoteSeatUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { VoteSeatUI } from './ui';
|
|
5
|
+
|
|
6
|
+
export type VoteSeatLocaleContent = ToolLocaleContent<VoteSeatUI>;
|
|
7
|
+
|
|
8
|
+
export const voteSeatDisproportionalityAnalyzer: CivicToolEntry<VoteSeatUI> = {
|
|
9
|
+
id: 'vote-seat-disproportionality-analyzer',
|
|
10
|
+
phase: 'localized',
|
|
11
|
+
icons: { bg: 'mdi:chart-box-outline', fg: 'mdi:scale-balance' },
|
|
12
|
+
i18n: {
|
|
13
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
14
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
15
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
16
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
17
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
18
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
19
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
20
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
21
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
22
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
23
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
25
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
26
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
28
|
+
},
|
|
29
|
+
};
|