@jjlmoya/utils-civic 1.1.0 → 1.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/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 +2 -1
- package/src/entries.ts +4 -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/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 +2 -0
- package/src/types.ts +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import { bibliography } from '../bibliography';
|
|
4
|
+
import type { VoteSeatLocaleContent } from '../entry';
|
|
5
|
+
const ui = { rowsLabel: '按政党或名单查看选票和席位', rowsHelp: '为相同的政党输入获得的选票和赢得的席位。每一列分别进行标准化。', partyNameLabel: '政党或名单', votesLabel: '选票', seatsLabel: '席位', addRow: '添加政党或名单', removeRow: '删除政党或名单', calculate: '测量差距', reset: '加载示例', resultLabel: '比例性压力', emptyState: '输入选票和席位,然后测量差距。', modelNotice: '模型比较得票率和席位率,不判断公平性、意识形态、合法性或选举质量。', totalVotesLabel: '选票总数', totalSeatsLabel: '席位总数', gallagherLabel: '加拉格尔指数', loosemoreHanbyLabel: '卢斯莫尔-汉比指数', sainteLagueLabel: '圣拉古指数', maxGapLabel: '最大份额差距', voteShareLabel: '得票率', seatShareLabel: '席位率', differenceLabel: '席位率减得票率', ratioLabel: '得票率与席位率之比', directionLabel: '解读', overrepresented: '席位率高于得票率', underrepresented: '席位率低于得票率', aligned: '份额一致', detailLabel: '政党详情', zeroVoteNote: '圣拉古指数的分母只使用至少有一票的行。没有选票的行仍会显示在其他指标中。', proportionNote: '指数越低,两个分布越接近。这些是描述性指标,不是法律阈值。', errorPrefix: '检查输入', exactParity: '完全一致: 每个得票率都等于对应的席位率。', resultGuide: '逐行阅读色带: 选票标记和席位标记之间的距离就是该政党的差距。' };
|
|
6
|
+
const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: '选票与席位不均衡分析器', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: '使用透明的选举数据测量得票率和席位率之间的差距。', url: 'https://gamebob.dev/zh/piao-shu-xi-shu-bili-shi-fenxi', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
|
|
7
|
+
const faqPage: FAQPage = { '@type': 'FAQPage', mainEntity: [{ '@type': 'Question', name: '这个分析测量什么?', acceptedAnswer: { '@type': 'Answer', text: '它比较每个政党的得票率和席位率,并用加拉格尔、卢斯莫尔-汉比和圣拉古指数概括差距。它不判断结果是否公平或合法。' } }, { '@type': 'Question', name: '为什么选票和席位的总数可以不同?', acceptedAnswer: { '@type': 'Answer', text: '它们是不同的单位。选票按选票总数标准化,席位按席位总数标准化。' } }, { '@type': 'Question', name: '如何解读得票率与席位率之比?', acceptedAnswer: { '@type': 'Answer', text: '这是得票率除以席位率。1表示一致,大于1表示席位率相对较低。' } }, { '@type': 'Question', name: '结果能证明选举制度不公平吗?', acceptedAnswer: { '@type': 'Answer', text: '不能。指数只描述两个分布之间的距离,还需要结合选区、门槛和分母来理解。' } }] };
|
|
8
|
+
const howTo: HowTo = { '@type': 'HowTo', name: '测量选票与席位的不均衡', step: [{ '@type': 'HowToStep', name: '输入政党', text: '为每个政党或名单添加一行。' }, { '@type': 'HowToStep', name: '输入选票和席位', text: '使用相同的选举范围并输入观测到的总数。' }, { '@type': 'HowToStep', name: '测量差距', text: '运行分析,标准化两列并计算指数。' }, { '@type': 'HowToStep', name: '阅读色带', text: '比较每一行的份额、差距和比率。' }, { '@type': 'HowToStep', name: '检查背景', text: '阅读公式并核对适用的选举规则。' }] };
|
|
9
|
+
export const content: ToolLocaleContent<VoteSeatLocaleContent['ui']> = { slug: 'vote-seat-disproportionality-analyzer', title: '选票与席位不均衡分析器', description: '根据透明的选举数据,使用加拉格尔、卢斯莫尔-汉比和圣拉古指数比较得票率与席位率。', ui, seo: [{ type: 'title', text: '测量选票与席位之间的差距', level: 2 }, { type: 'paragraph', html: '一次选举可能让某个政党的席位率与得票率大不相同。本分析把两个观测到的分布变得可比较,并显示哪些政党相对于所得选票获得了更多或更少的代表性。选票和席位分别标准化。' }, { type: 'paragraph', html: '结果不是裁决。正差距表示席位率更高,负差距表示席位率更低。色带会先显示方向,再展示汇总指数。' }, { type: 'title', text: '三个指数分别说明什么', level: 2 }, { type: 'paragraph', html: '加拉格尔指数是百分点差距平方和一半的平方根,因此较大的偏差权重更高。卢斯莫尔-汉比指数把绝对差距相加后除以二。' }, { type: 'paragraph', html: '圣拉古指数使用各项份额,并将差距平方除以每个有选票行的得票率。没有选票的行无法提供这个分母,因此只保留在另外两个指标中。' }, { type: 'table', headers: ['指标', '计算方式', '有用的问题'], rows: [['加拉格尔', '百分点差距平方和一半的平方根。', '哪些大差距主导整体距离?'], ['卢斯莫尔-汉比', '绝对百分点差距总和的一半。', '整体分布相差多少?'], ['圣拉古', '份额差距平方除以得票率。', '分母如何影响小党?']] }, { type: 'title', text: '准备可比较的选举数据', level: 2 }, { type: 'paragraph', html: '使用相同的选举范围,不要把全国选票与地区席位混在一起。总数不必相同,但每一行必须描述同一个政党和同一个结果。' }, { type: 'list', items: ['在两列中清楚标出同一个对象。', '包含有选票但没有席位的政党。', '只有在数据中有记录时才使用零。', '确认有效票、无效票或排除票哪些应进入分母。', '将指数和数据来源一起保存,以便复现结果。'] }, { type: 'tip', title: '比率不是原因', html: '大于1表示席位率低于得票率。选区规模、门槛、地域集中度、投票率和席位分配公式都可能造成差距。' }, { type: 'title', text: '比较前了解限制', level: 2 }, { type: 'paragraph', html: '较低的数值只表示输入的分布更加接近,并不自动表示选举制度更好。只比较对选票、席位、政党、地区和分母有兼容定义的数据。' }, { type: 'paragraph', html: '本分析不推断意识形态、意图、协议、法律合规性或民主合法性,也不会修正官方结果或重建某个地区的计票过程。' }, { type: 'tip', title: '从距离最远的色带开始', html: '先检查差距最大的政党,比较原始数值,然后确认每一行都按相同方式处理。' }], faq: [{ question: '分析测量什么?', answer: '它比较得票率和席位率,并用三个指数概括距离。' }, { question: '为什么总数不同?', answer: '选票和席位是不同单位,各自使用自己的总数标准化。' }, { question: '如何阅读比率?', answer: '得票率除以席位率,1表示一致。' }, { question: '这是不公平的证据吗?', answer: '不是。它描述距离,需要结合选举规则来解释。' }], bibliography, howTo: [{ name: '输入政党', text: '为每个政党或名单添加一行。' }, { name: '输入数值', text: '输入相同范围内的选票和席位。' }, { name: '测量差距', text: '运行分析并计算指数。' }, { name: '阅读色带', text: '比较份额和差距。' }, { name: '检查背景', text: '核对适用的选举规则。' }], schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[] };
|
|
10
|
+
export const chineseContent: VoteSeatLocaleContent = content;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { voteSeatDisproportionalityAnalyzer } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
export * from './logic';
|
|
6
|
+
export const VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: voteSeatDisproportionalityAnalyzer,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateDisproportionality } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('calculateDisproportionality', () => {
|
|
5
|
+
it('returns zero indices for a proportional result', () => {
|
|
6
|
+
const result = calculateDisproportionality({ rows: [
|
|
7
|
+
{ name: 'North', votes: 60, seats: 6 },
|
|
8
|
+
{ name: 'South', votes: 40, seats: 4 },
|
|
9
|
+
] });
|
|
10
|
+
expect(result.gallagherIndex).toBe(0);
|
|
11
|
+
expect(result.loosemoreHanbyIndex).toBe(0);
|
|
12
|
+
expect(result.sainteLagueIndex).toBe(0);
|
|
13
|
+
expect(result.parties[0]?.voteToSeatRatio).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('normalizes votes and seats with their own totals', () => {
|
|
17
|
+
const result = calculateDisproportionality({ rows: [
|
|
18
|
+
{ name: 'Alpha', votes: 600, seats: 2 },
|
|
19
|
+
{ name: 'Beta', votes: 400, seats: 8 },
|
|
20
|
+
] });
|
|
21
|
+
expect(result.totalVotes).toBe(1000);
|
|
22
|
+
expect(result.totalSeats).toBe(10);
|
|
23
|
+
expect(result.parties[0]?.voteSharePercent).toBe(60);
|
|
24
|
+
expect(result.parties[0]?.seatSharePercent).toBe(20);
|
|
25
|
+
expect(result.parties[0]?.differencePercent).toBe(-40);
|
|
26
|
+
expect(result.parties[0]?.direction).toBe('underrepresented');
|
|
27
|
+
expect(result.gallagherIndex).toBe(40);
|
|
28
|
+
expect(result.loosemoreHanbyIndex).toBe(40);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('handles zero votes and zero seats explicitly', () => {
|
|
32
|
+
const result = calculateDisproportionality({ rows: [
|
|
33
|
+
{ name: 'Absent', votes: 0, seats: 0 },
|
|
34
|
+
{ name: 'Winner', votes: 100, seats: 10 },
|
|
35
|
+
] });
|
|
36
|
+
expect(result.parties[0]?.voteToSeatRatio).toBeNull();
|
|
37
|
+
expect(result.parties[1]?.voteToSeatRatio).toBe(1);
|
|
38
|
+
expect(result.sainteLagueRows).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows infinite vote to seat ratio for a vote-only row', () => {
|
|
42
|
+
const result = calculateDisproportionality({ rows: [
|
|
43
|
+
{ name: 'Unseated', votes: 25, seats: 0 },
|
|
44
|
+
{ name: 'Seated', votes: 75, seats: 10 },
|
|
45
|
+
] });
|
|
46
|
+
expect(result.parties[0]?.voteToSeatRatio).toBe(Infinity);
|
|
47
|
+
expect(result.parties[0]?.direction).toBe('underrepresented');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects empty, invalid, duplicate and zero-total input', () => {
|
|
51
|
+
expect(() => calculateDisproportionality({ rows: [] })).toThrow('at least one party');
|
|
52
|
+
expect(() => calculateDisproportionality({ rows: [{ name: 'A', votes: 1, seats: 0 }] })).toThrow('at least one seat');
|
|
53
|
+
expect(() => calculateDisproportionality({ rows: [{ name: 'A', votes: 0, seats: 1 }] })).toThrow('at least one vote');
|
|
54
|
+
expect(() => calculateDisproportionality({ rows: [
|
|
55
|
+
{ name: 'A', votes: -1, seats: 1 }, { name: 'a', votes: 1, seats: 1 },
|
|
56
|
+
] })).toThrow('positive number');
|
|
57
|
+
expect(() => calculateDisproportionality({ rows: [
|
|
58
|
+
{ name: 'A', votes: 1, seats: 1 }, { name: ' a ', votes: 1, seats: 1 },
|
|
59
|
+
] })).toThrow('unique');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface VoteSeatRow {
|
|
2
|
+
name: string;
|
|
3
|
+
votes: number;
|
|
4
|
+
seats: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface VoteSeatInput {
|
|
8
|
+
rows: VoteSeatRow[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RepresentationDirection = 'overrepresented' | 'underrepresented' | 'aligned';
|
|
12
|
+
|
|
13
|
+
export interface PartyDisproportionality {
|
|
14
|
+
name: string;
|
|
15
|
+
votes: number;
|
|
16
|
+
seats: number;
|
|
17
|
+
voteSharePercent: number;
|
|
18
|
+
seatSharePercent: number;
|
|
19
|
+
differencePercent: number;
|
|
20
|
+
voteToSeatRatio: number | null;
|
|
21
|
+
direction: RepresentationDirection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DisproportionalityResult {
|
|
25
|
+
totalVotes: number;
|
|
26
|
+
totalSeats: number;
|
|
27
|
+
parties: PartyDisproportionality[];
|
|
28
|
+
gallagherIndex: number;
|
|
29
|
+
loosemoreHanbyIndex: number;
|
|
30
|
+
sainteLagueIndex: number;
|
|
31
|
+
sainteLagueRows: number;
|
|
32
|
+
maxAbsoluteDifferencePercent: number;
|
|
33
|
+
largestGapParty: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const EPSILON = 1e-9;
|
|
37
|
+
|
|
38
|
+
const roundValue = (value: number, digits = 6): number => {
|
|
39
|
+
const factor = 10 ** digits;
|
|
40
|
+
return Math.round(value * factor) / factor;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const assertRows = (rows: VoteSeatRow[]): void => {
|
|
44
|
+
if (!Array.isArray(rows) || rows.length === 0) throw new RangeError('Enter at least one party or list.');
|
|
45
|
+
const names = new Set<string>();
|
|
46
|
+
rows.forEach((row) => {
|
|
47
|
+
const name = row.name.trim();
|
|
48
|
+
if (!name) throw new RangeError('Each row needs a party or list name.');
|
|
49
|
+
const key = name.toLocaleLowerCase();
|
|
50
|
+
if (names.has(key)) throw new RangeError('Party or list names must be unique.');
|
|
51
|
+
if (!Number.isFinite(row.votes) || row.votes < 0) throw new RangeError('Votes must be zero or a positive number.');
|
|
52
|
+
if (!Number.isFinite(row.seats) || row.seats < 0) throw new RangeError('Seats must be zero or a positive number.');
|
|
53
|
+
names.add(key);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const assertTotals = (rows: VoteSeatRow[]): { totalVotes: number; totalSeats: number } => {
|
|
58
|
+
const totalVotes = rows.reduce((sum, row) => sum + row.votes, 0);
|
|
59
|
+
const totalSeats = rows.reduce((sum, row) => sum + row.seats, 0);
|
|
60
|
+
if (totalVotes <= 0) throw new RangeError('Enter at least one vote before calculating.');
|
|
61
|
+
if (totalSeats <= 0) throw new RangeError('Enter at least one seat before calculating.');
|
|
62
|
+
return { totalVotes, totalSeats };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const getDirection = (differencePercent: number): RepresentationDirection => {
|
|
66
|
+
if (Math.abs(differencePercent) < EPSILON) return 'aligned';
|
|
67
|
+
return differencePercent > 0 ? 'overrepresented' : 'underrepresented';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const getRatio = (voteShare: number, seatShare: number): number | null => {
|
|
71
|
+
if (voteShare === 0 && seatShare === 0) return null;
|
|
72
|
+
if (seatShare === 0) return Infinity;
|
|
73
|
+
return roundValue(voteShare / seatShare, 6);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const buildParty = (row: VoteSeatRow, totalVotes: number, totalSeats: number): PartyDisproportionality => {
|
|
77
|
+
const voteShare = row.votes / totalVotes;
|
|
78
|
+
const seatShare = row.seats / totalSeats;
|
|
79
|
+
const differencePercent = roundValue((seatShare - voteShare) * 100, 6);
|
|
80
|
+
return {
|
|
81
|
+
name: row.name.trim(),
|
|
82
|
+
votes: row.votes,
|
|
83
|
+
seats: row.seats,
|
|
84
|
+
voteSharePercent: roundValue(voteShare * 100, 6),
|
|
85
|
+
seatSharePercent: roundValue(seatShare * 100, 6),
|
|
86
|
+
differencePercent,
|
|
87
|
+
voteToSeatRatio: getRatio(voteShare, seatShare),
|
|
88
|
+
direction: getDirection(differencePercent),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const getSainteLagueIndex = (parties: PartyDisproportionality[], totalVotes: number, totalSeats: number): number => parties.reduce((sum, party) => {
|
|
93
|
+
if (party.votes === 0) return sum;
|
|
94
|
+
const voteShare = party.votes / totalVotes;
|
|
95
|
+
const seatShare = party.seats / totalSeats;
|
|
96
|
+
return sum + ((seatShare - voteShare) ** 2) / voteShare;
|
|
97
|
+
}, 0);
|
|
98
|
+
|
|
99
|
+
export const calculateDisproportionality = (input: VoteSeatInput): DisproportionalityResult => {
|
|
100
|
+
assertRows(input.rows);
|
|
101
|
+
const { totalVotes, totalSeats } = assertTotals(input.rows);
|
|
102
|
+
const parties = input.rows.map((row) => buildParty(row, totalVotes, totalSeats));
|
|
103
|
+
const gaps = parties.map((party) => Math.abs(party.differencePercent));
|
|
104
|
+
const maxAbsoluteDifferencePercent = Math.max(...gaps);
|
|
105
|
+
const largestGapParty = parties.find((party) => Math.abs(party.differencePercent) === maxAbsoluteDifferencePercent)?.name ?? '';
|
|
106
|
+
const gallagherIndex = Math.sqrt(0.5 * parties.reduce((sum, party) => sum + party.differencePercent ** 2, 0));
|
|
107
|
+
const loosemoreHanbyIndex = 0.5 * parties.reduce((sum, party) => sum + Math.abs(party.differencePercent), 0);
|
|
108
|
+
return {
|
|
109
|
+
totalVotes,
|
|
110
|
+
totalSeats,
|
|
111
|
+
parties,
|
|
112
|
+
gallagherIndex: roundValue(gallagherIndex),
|
|
113
|
+
loosemoreHanbyIndex: roundValue(loosemoreHanbyIndex),
|
|
114
|
+
sainteLagueIndex: roundValue(getSainteLagueIndex(parties, totalVotes, totalSeats)),
|
|
115
|
+
sainteLagueRows: parties.filter((party) => party.votes > 0).length,
|
|
116
|
+
maxAbsoluteDifferencePercent: roundValue(maxAbsoluteDifferencePercent),
|
|
117
|
+
largestGapParty,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { voteSeatDisproportionalityAnalyzer } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const content = await voteSeatDisproportionalityAnalyzer.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { VoteSeatInput } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'civic-vote-seat-disproportionality';
|
|
4
|
+
|
|
5
|
+
export const loadVoteSeatInput = (): VoteSeatInput | null => {
|
|
6
|
+
try {
|
|
7
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
8
|
+
return raw ? JSON.parse(raw) as VoteSeatInput : null;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const saveVoteSeatInput = (input: VoteSeatInput): void => {
|
|
15
|
+
try {
|
|
16
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(input));
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const clearVoteSeatInput = (): void => {
|
|
23
|
+
try {
|
|
24
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface VoteSeatUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
rowsLabel: string;
|
|
4
|
+
rowsHelp: string;
|
|
5
|
+
partyNameLabel: string;
|
|
6
|
+
votesLabel: string;
|
|
7
|
+
seatsLabel: string;
|
|
8
|
+
addRow: string;
|
|
9
|
+
removeRow: string;
|
|
10
|
+
calculate: string;
|
|
11
|
+
reset: string;
|
|
12
|
+
resultLabel: string;
|
|
13
|
+
emptyState: string;
|
|
14
|
+
modelNotice: string;
|
|
15
|
+
totalVotesLabel: string;
|
|
16
|
+
totalSeatsLabel: string;
|
|
17
|
+
gallagherLabel: string;
|
|
18
|
+
loosemoreHanbyLabel: string;
|
|
19
|
+
sainteLagueLabel: string;
|
|
20
|
+
maxGapLabel: string;
|
|
21
|
+
voteShareLabel: string;
|
|
22
|
+
seatShareLabel: string;
|
|
23
|
+
differenceLabel: string;
|
|
24
|
+
ratioLabel: string;
|
|
25
|
+
directionLabel: string;
|
|
26
|
+
overrepresented: string;
|
|
27
|
+
underrepresented: string;
|
|
28
|
+
aligned: string;
|
|
29
|
+
detailLabel: string;
|
|
30
|
+
zeroVoteNote: string;
|
|
31
|
+
proportionNote: string;
|
|
32
|
+
errorPrefix: string;
|
|
33
|
+
exactParity: string;
|
|
34
|
+
resultGuide: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ui: VoteSeatUI = {
|
|
38
|
+
rowsLabel: 'Votes and seats by party or list',
|
|
39
|
+
rowsHelp: 'Enter the votes received and seats won for the same set of parties. Each column is normalized separately.',
|
|
40
|
+
partyNameLabel: 'Party or list',
|
|
41
|
+
votesLabel: 'Votes',
|
|
42
|
+
seatsLabel: 'Seats',
|
|
43
|
+
addRow: 'Add party or list',
|
|
44
|
+
removeRow: 'Remove party or list',
|
|
45
|
+
calculate: 'Measure the gap',
|
|
46
|
+
reset: 'Load example',
|
|
47
|
+
resultLabel: 'Proportionality press',
|
|
48
|
+
emptyState: 'Enter votes and seats, then measure the gap.',
|
|
49
|
+
modelNotice: 'The model compares vote share with seat share. It does not judge fairness, ideology, legality, or election quality.',
|
|
50
|
+
totalVotesLabel: 'Total votes',
|
|
51
|
+
totalSeatsLabel: 'Total seats',
|
|
52
|
+
gallagherLabel: 'Gallagher index',
|
|
53
|
+
loosemoreHanbyLabel: 'Loosemore-Hanby',
|
|
54
|
+
sainteLagueLabel: 'Sainte-Lague index',
|
|
55
|
+
maxGapLabel: 'Largest share gap',
|
|
56
|
+
voteShareLabel: 'Vote share',
|
|
57
|
+
seatShareLabel: 'Seat share',
|
|
58
|
+
differenceLabel: 'Seat share minus vote share',
|
|
59
|
+
ratioLabel: 'Vote to seat ratio',
|
|
60
|
+
directionLabel: 'Reading',
|
|
61
|
+
overrepresented: 'More seats than vote share',
|
|
62
|
+
underrepresented: 'Fewer seats than vote share',
|
|
63
|
+
aligned: 'Shares align',
|
|
64
|
+
detailLabel: 'Party detail',
|
|
65
|
+
zeroVoteNote: 'Sainte-Lague uses only rows with at least one vote in its denominator. Zero-vote rows remain visible in the other measures.',
|
|
66
|
+
proportionNote: 'Lower index values mean the two distributions are closer. The values are descriptive measures, not a legal threshold.',
|
|
67
|
+
errorPrefix: 'Check the input',
|
|
68
|
+
exactParity: 'Exact parity: every vote share matches its seat share.',
|
|
69
|
+
resultGuide: 'Read the ribbons row by row: the distance between the vote and seat marks is the party-level gap.',
|
|
70
|
+
};
|