@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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/de.ts +12 -9
  3. package/src/category/i18n/en.ts +12 -9
  4. package/src/category/i18n/es.ts +12 -9
  5. package/src/category/i18n/fr.ts +12 -9
  6. package/src/category/i18n/id.ts +12 -9
  7. package/src/category/i18n/it.ts +12 -9
  8. package/src/category/i18n/ja.ts +12 -9
  9. package/src/category/i18n/ko.ts +12 -9
  10. package/src/category/i18n/nl.ts +12 -9
  11. package/src/category/i18n/pl.ts +12 -9
  12. package/src/category/i18n/pt.ts +12 -9
  13. package/src/category/i18n/ru.ts +12 -9
  14. package/src/category/i18n/sv.ts +12 -9
  15. package/src/category/i18n/tr.ts +12 -9
  16. package/src/category/i18n/zh.ts +12 -8
  17. package/src/category/index.ts +2 -1
  18. package/src/entries.ts +4 -0
  19. package/src/index.ts +1 -0
  20. package/src/tests/i18n_coverage.test.ts +8 -0
  21. package/src/tests/locale_completeness.test.ts +1 -1
  22. package/src/tests/tool_validation.test.ts +1 -1
  23. package/src/tool/vote-seat-disproportionality-analyzer/bibliography.astro +6 -0
  24. package/src/tool/vote-seat-disproportionality-analyzer/bibliography.ts +16 -0
  25. package/src/tool/vote-seat-disproportionality-analyzer/component.astro +48 -0
  26. package/src/tool/vote-seat-disproportionality-analyzer/controller.ts +165 -0
  27. package/src/tool/vote-seat-disproportionality-analyzer/dom-views.ts +123 -0
  28. package/src/tool/vote-seat-disproportionality-analyzer/entry.ts +29 -0
  29. package/src/tool/vote-seat-disproportionality-analyzer/evaluator.ts +15 -0
  30. package/src/tool/vote-seat-disproportionality-analyzer/i18n/de.ts +44 -0
  31. package/src/tool/vote-seat-disproportionality-analyzer/i18n/en.ts +110 -0
  32. package/src/tool/vote-seat-disproportionality-analyzer/i18n/es.ts +42 -0
  33. package/src/tool/vote-seat-disproportionality-analyzer/i18n/fr.ts +37 -0
  34. package/src/tool/vote-seat-disproportionality-analyzer/i18n/id.ts +15 -0
  35. package/src/tool/vote-seat-disproportionality-analyzer/i18n/it.ts +10 -0
  36. package/src/tool/vote-seat-disproportionality-analyzer/i18n/ja.ts +10 -0
  37. package/src/tool/vote-seat-disproportionality-analyzer/i18n/ko.ts +10 -0
  38. package/src/tool/vote-seat-disproportionality-analyzer/i18n/nl.ts +10 -0
  39. package/src/tool/vote-seat-disproportionality-analyzer/i18n/pl.ts +10 -0
  40. package/src/tool/vote-seat-disproportionality-analyzer/i18n/pt.ts +10 -0
  41. package/src/tool/vote-seat-disproportionality-analyzer/i18n/ru.ts +10 -0
  42. package/src/tool/vote-seat-disproportionality-analyzer/i18n/sv.ts +10 -0
  43. package/src/tool/vote-seat-disproportionality-analyzer/i18n/tr.ts +10 -0
  44. package/src/tool/vote-seat-disproportionality-analyzer/i18n/zh.ts +10 -0
  45. package/src/tool/vote-seat-disproportionality-analyzer/index.ts +11 -0
  46. package/src/tool/vote-seat-disproportionality-analyzer/logic.test.ts +61 -0
  47. package/src/tool/vote-seat-disproportionality-analyzer/logic.ts +119 -0
  48. package/src/tool/vote-seat-disproportionality-analyzer/seo.astro +14 -0
  49. package/src/tool/vote-seat-disproportionality-analyzer/storage.ts +28 -0
  50. package/src/tool/vote-seat-disproportionality-analyzer/ui.ts +70 -0
  51. package/src/tool/vote-seat-disproportionality-analyzer/vote-seat-disproportionality-analyzer.css +467 -0
  52. package/src/tools.ts +2 -0
  53. package/src/types.ts +1 -1
@@ -2,19 +2,22 @@ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'kamusal',
5
- title: 'Kamusal karar araçları',
6
- description: 'Şeffaf varsayımlarla kamu karar sistemlerini incelemek için çevrimdışı araçlar; başlangıç noktası seçim sandalyesi dağılımı modelidir.',
5
+ title: 'Seçimler, bütçeler ve kamu verileri için araçlar',
6
+ description: 'Seçimleri, koalisyonları, referandumları, kamu bütçelerini, açık verileri, parlamento oylarını ve bilgiye erişimi açık varsayımlarla inceleyen yurttaşlık araçları.',
7
7
  seo: [
8
- { type: 'title', text: 'Oy toplamlarından temsili modellemek', level: 2 },
9
- { type: 'paragraph', html: 'İlk kamusal araç, parti veya liste oylarını izlenebilir bir sandalye dağılımına dönüştürür. Yöntem, seçim barajı, bölüm sırası, kalan oy sırası ve oylarla sandalyeler arasındaki fark adım adım görünür.' },
10
- { type: 'title', text: 'Yöntemleri ve varsayımları karşılaştırın', level: 2 },
11
- { type: 'paragraph', html: 'Kamusal modeller, varsayımları açık kaldığında işe yarar. Aynı girdilerle D\'Hondt, Sainte-Laguë ve Hare en büyük kalan yöntemlerini karşılaştırın; ardından barajın veya seçim bölgesi büyüklüğünün sonucu nasıl değiştirdiğini inceleyin. Çıktı eğitseldir ve resmî sayımın ya da belirli bir seçim hukukunun yerini tutmaz.' },
12
- { type: 'title', text: 'Varsayılan olarak özel', level: 2 },
13
- { type: 'paragraph', html: 'Girdiler tarayıcıda işlenir. Hesap açmak gerekmez. Model, dersler, kamusal açıklamalar ve oy senaryolarını bir hizmete göndermeden yapılan dikkatli varsayım çalışmaları için tasarlanmıştır.' },
8
+ { type: 'title', text: 'Seçimleri ve ortak kararları inceleyin', level: 2 },
9
+ { type: 'paragraph', html: 'Bu yurttaşlık araçları koleksiyonu, kamu karar sistemlerini anlamayı kolaylaştırır. Seçim sandalye dağılımıyla başlar ve koalisyon çoğunlukları, referandum eşikleri, parlamento oyları ve oyların karara dönüşme süreci için araçlar sunacak şekilde tasarlanmıştır.' },
10
+ { type: 'title', text: 'Kuralları gizlemeden temsili karşılaştırın', level: 2 },
11
+ { type: 'paragraph', html: 'Bir seçim senaryosu, varsayımları görünür olduğunda anlamlıdır. Sandalye dağılımı modeliyle D\'Hondt, Sainte-Laguë ve Hare en büyük kalan yöntemlerini karşılaştırın; ardından barajları, seçim bölgesi büyüklüğünü, oy paylarını, sandalye paylarını ve dağıtım sırasını inceleyin. Bunlar öğrenme ve açıklama senaryolarıdır, resmî sonuç değildir.' },
12
+ { type: 'title', text: 'Kamu önceliklerini bütçe senaryolarına dönüştürün', level: 2 },
13
+ { type: 'paragraph', html: 'Planlanan araçlar, öncelikleri anlaşılır bütçe senaryolarına dönüştürmeye de yardımcı olacaktır. Bir öncelik matrisinin kamu ihtiyaçlarını nasıl sıraladığını, katılımcı bütçenin kısıtlar altında nasıl dağıtılacağını ve kamu bütçesinin toplamlar, kategoriler ve tercihler üzerinden nasıl okunacağını inceleyin.' },
14
+ { type: 'title', text: 'Açık verileri ve kamu hesap verebilirliğini inceleyin', level: 2 },
15
+ { type: 'paragraph', html: 'Kamusal bir karar, arkasındaki bilginin kalitesine bağlıdır. Planlanan koleksiyon açık veri kalitesini profillemek, parlamento oylarını analiz etmek ve daha açık bilgi edinme başvuruları hazırlamak için araçlar içerir; böylece eksikler ve sorular daha kolay belgelenebilir.' },
16
+ { type: 'title', text: 'Şeffaf senaryolar, açık sınırlar ve tarayıcı gizliliği', level: 2 },
17
+ { type: 'paragraph', html: 'Her araç girdileri, yöntemi, varsayımları ve sınırları anlaşılır bir dille göstermeyi amaçlar. Koleksiyon derslerde, gazetecilikte, topluluk çalışmalarında ve dikkatli ne olurdu analizlerinde kullanılabilir. Tarayıcıdaki senaryolar resmî sayımın, belirli bir hukuk kuralının veya uzman görüşünün yerini tutmaz.' },
14
18
  { type: 'stats', items: [
15
19
  { label: 'Araç', value: '1', icon: 'mdi:tools' },
16
20
  { label: 'Yöntem', value: '3', icon: 'mdi:scale-balance' },
17
- { label: 'Mod', value: 'Çevrimdışı', icon: 'mdi:lock-outline' },
18
21
  { label: 'Durum', value: 'Çok dilli', icon: 'mdi:translate' },
19
22
  ] },
20
23
  ],
@@ -2,15 +2,19 @@ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'civic',
5
- title: '公共决策工具',
6
- description: '用透明的假设探索公共决策制度的离线工具,首先提供选举席位分配模型。',
5
+ title: '选举、预算与公共数据的公民工具',
6
+ description: '用清晰的假设探索选举、联盟、公投、公共预算、开放数据、议会投票和信息公开的公民工具。',
7
7
  seo: [
8
- { type: 'title', text: '从得票数建立代表性模型', level: 2 },
9
- { type: 'paragraph', html: '第一个公共工具把政党或名单的得票数转换为可追溯的席位分配。用户可以逐步查看分配方法、门槛、商的顺序、余数的顺序,以及得票与席位之间的差异。' },
10
- { type: 'title', text: '比较方法与假设', level: 2 },
11
- { type: 'paragraph', html: '公共制度模型只有在假设清晰时才有帮助。用相同输入比较顿特法、圣拉古法和黑尔最大余数法,再测试门槛或选区席位数如何改变结果。输出用于学习和解释,不能替代官方计票或任何特定地区的选举法。' },
12
- { type: 'title', text: '默认在浏览器中处理', level: 2 },
13
- { type: 'paragraph', html: '输入在浏览器内处理,不需要账户。它适合课堂练习、公共说明和谨慎的假设分析,不会把投票情景发送给外部服务。' },
8
+ { type: 'title', text: '探索选举与集体决策', level: 2 },
9
+ { type: 'paragraph', html: '这组公民工具旨在让公共决策制度更容易理解。它从选举席位分配开始,并计划覆盖联盟多数、公投门槛、议会投票模式,以及得票如何转化为公共决策等问题。' },
10
+ { type: 'title', text: '不隐藏规则,比较代表性差异', level: 2 },
11
+ { type: 'paragraph', html: '选举情景只有在假设清晰时才有意义。使用席位分配模型比较顿特法、圣拉古法和黑尔最大余数法,再查看门槛、选区规模、得票率、席位率和席位分配顺序。这些情景用于学习和解释,不是官方结果。' },
12
+ { type: 'title', text: '把公共优先事项转化为预算情景', level: 2 },
13
+ { type: 'paragraph', html: '计划中的工具还将帮助把优先事项转化为易于理解的预算情景。探索优先级矩阵如何排列公共需求,参与式预算如何在限制条件下分配,以及如何通过总额、类别和权衡来阅读公共预算。' },
14
+ { type: 'title', text: '检查开放数据与公共问责', level: 2 },
15
+ { type: 'paragraph', html: '公共决策取决于支撑它的信息质量。计划中的工具包括开放数据质量分析、议会投票分析和信息公开申请构建,帮助记录信息缺口、提出清晰问题并追踪依据。' },
16
+ { type: 'title', text: '为公共决策提供清晰情景', level: 2 },
17
+ { type: 'paragraph', html: '每个工具都计划用易懂的语言展示输入、方法、假设和限制。它们适合课堂、新闻报道、社区工作和谨慎的假设分析。工具帮助清晰解释问题,但不能替代官方计票、具体法律规则或专业意见。' },
14
18
  { type: 'stats', items: [
15
19
  { label: '工具', value: '1', icon: 'mdi:tools' },
16
20
  { label: '方法', value: '3', icon: 'mdi:scale-balance' },
@@ -1,9 +1,10 @@
1
1
  import { electionSeatApportionmentCalculator } from '../tool/election-seat-apportionment-calculator/entry';
2
+ import { voteSeatDisproportionalityAnalyzer } from '../tool/vote-seat-disproportionality-analyzer/entry';
2
3
  import type { CategoryLocaleContent, KnownLocale } from '../types';
3
4
 
4
5
  export const civicCategory = {
5
6
  icon: 'mdi:bank-outline',
6
- tools: [electionSeatApportionmentCalculator],
7
+ tools: [electionSeatApportionmentCalculator, voteSeatDisproportionalityAnalyzer],
7
8
  i18n: {
8
9
  en: () => import('./i18n/en').then((m) => m.content),
9
10
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export { electionSeatApportionmentCalculator } from './tool/election-seat-apportionment-calculator/entry';
2
2
  export type { ElectionSeatUI, ElectionSeatLocaleContent } from './tool/election-seat-apportionment-calculator/entry';
3
+ export { voteSeatDisproportionalityAnalyzer } from './tool/vote-seat-disproportionality-analyzer/entry';
4
+ export type { VoteSeatUI, VoteSeatLocaleContent } from './tool/vote-seat-disproportionality-analyzer/entry';
3
5
  export { civicCategory } from './category';
4
6
 
5
7
  import { electionSeatApportionmentCalculator } from './tool/election-seat-apportionment-calculator/entry';
8
+ import { voteSeatDisproportionalityAnalyzer } from './tool/vote-seat-disproportionality-analyzer/entry';
6
9
 
7
10
  export const ALL_ENTRIES = [
8
11
  electionSeatApportionmentCalculator,
12
+ voteSeatDisproportionalityAnalyzer,
9
13
  ];
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { civicCategory } from './category';
2
2
  export const civicCategorySEO = () => import('./category/CivicCategorySEO.astro').then((m) => m.default);
3
3
 
4
4
  export { electionSeatApportionmentCalculator, ELECTION_SEAT_APPORTIONMENT_CALCULATOR_TOOL } from './tool/election-seat-apportionment-calculator';
5
+ export { voteSeatDisproportionalityAnalyzer, VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL } from './tool/vote-seat-disproportionality-analyzer';
5
6
 
6
7
  export type {
7
8
  KnownLocale,
@@ -11,6 +11,10 @@ describe('I18n Coverage Validation', () => {
11
11
  ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
12
12
  describe(`Tool: ${entry.id}`, () => {
13
13
  it('should have all 15 required locales', () => {
14
+ if (entry.phase === 'english-first') {
15
+ expect(Object.keys(entry.i18n)).toEqual(['en']);
16
+ return;
17
+ }
14
18
  const registeredLocales = Object.keys(entry.i18n);
15
19
  EXPECTED_LOCALES.forEach((locale) => {
16
20
  expect(
@@ -21,6 +25,10 @@ describe('I18n Coverage Validation', () => {
21
25
  });
22
26
 
23
27
  it('all locale loaders should be functions', () => {
28
+ if (entry.phase === 'english-first') {
29
+ expect(typeof entry.i18n.en).toBe('function');
30
+ return;
31
+ }
24
32
  EXPECTED_LOCALES.forEach((locale) => {
25
33
  const loader = entry.i18n[locale as keyof typeof entry.i18n];
26
34
  expect(
@@ -18,6 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(1);
21
+ expect(ALL_TOOLS.length).toBe(2);
22
22
  });
23
23
  });
@@ -5,7 +5,7 @@ import { civicCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(1);
8
+ expect(ALL_TOOLS.length).toBe(2);
9
9
  });
10
10
 
11
11
  it('civicCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <Bibliography links={bibliography} />
@@ -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
+ };
@@ -0,0 +1,15 @@
1
+ import type { DisproportionalityResult } from './logic';
2
+ import type { VoteSeatUI } from './ui';
3
+
4
+ export interface DisproportionalityStatus {
5
+ tone: 'positive' | 'caution' | 'neutral';
6
+ text: string;
7
+ }
8
+
9
+ export const evaluateDisproportionality = (result: DisproportionalityResult, ui: VoteSeatUI): DisproportionalityStatus => {
10
+ if (result.gallagherIndex === 0) return { tone: 'positive', text: ui.exactParity };
11
+ return {
12
+ tone: result.maxAbsoluteDifferencePercent >= 10 ? 'caution' : 'neutral',
13
+ text: `${ui.maxGapLabel}: ${result.maxAbsoluteDifferencePercent.toFixed(2)} percentage points (${result.largestGapParty}).`,
14
+ };
15
+ };
@@ -0,0 +1,44 @@
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
+
6
+ const ui = {
7
+ rowsLabel: 'Stimmen und Sitze je Partei oder Liste', rowsHelp: 'Gib für dieselben Parteien die erhaltenen Stimmen und gewonnenen Sitze ein. Jede Spalte wird getrennt normiert.', partyNameLabel: 'Partei oder Liste', votesLabel: 'Stimmen', seatsLabel: 'Sitze', addRow: 'Partei oder Liste hinzufügen', removeRow: 'Partei oder Liste entfernen', calculate: 'Abstand messen', reset: 'Beispiel laden', resultLabel: 'Proportionalitätspresse', emptyState: 'Stimmen und Sitze eingeben und den Abstand messen.', modelNotice: 'Das Modell vergleicht Stimmenanteil und Sitzanteil. Es bewertet weder Fairness noch Ideologie, Rechtmäßigkeit oder Wahlqualität.', totalVotesLabel: 'Stimmen insgesamt', totalSeatsLabel: 'Sitze insgesamt', gallagherLabel: 'Gallagher-Index', loosemoreHanbyLabel: 'Loosemore-Hanby', sainteLagueLabel: 'Sainte-Laguë-Index', maxGapLabel: 'Größter Anteilsabstand', voteShareLabel: 'Stimmenanteil', seatShareLabel: 'Sitzanteil', differenceLabel: 'Sitzanteil minus Stimmenanteil', ratioLabel: 'Stimmen-Sitz-Verhältnis', directionLabel: 'Lesart', overrepresented: 'Mehr Sitze als Stimmenanteil', underrepresented: 'Weniger Sitze als Stimmenanteil', aligned: 'Anteile stimmen überein', detailLabel: 'Parteidetails', zeroVoteNote: 'Der Sainte-Laguë-Index verwendet nur Zeilen mit mindestens einer Stimme im Nenner. Zeilen ohne Stimmen bleiben in den anderen Maßen sichtbar.', proportionNote: 'Niedrigere Indexwerte bedeuten, dass beide Verteilungen näher beieinanderliegen. Die Werte sind beschreibend und keine Rechtsgrenze.', errorPrefix: 'Eingabe prüfen', exactParity: 'Exakte Parität: Jeder Stimmenanteil entspricht seinem Sitzanteil.', resultGuide: 'Lies die Bänder zeilenweise: Der Abstand zwischen Stimmen- und Sitzmarke ist die Lücke der Partei.',
8
+ };
9
+
10
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Analyse der Abweichung von Stimmen und Sitzen', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Misst die Abweichung zwischen Stimmenanteilen und Sitzanteilen mit etablierten Indizes.', url: 'https://gamebob.dev/de/analyse-stimmen-sitze-wahlproportionalitaet', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
11
+ const faqPage: FAQPage = { '@type': 'FAQPage', mainEntity: [
12
+ { '@type': 'Question', name: 'Was misst die Analyse?', acceptedAnswer: { '@type': 'Answer', text: 'Sie vergleicht für jede Partei Stimmen- und Sitzanteil und fasst den Abstand mit Gallagher, Loosemore-Hanby und Sainte-Laguë zusammen. Sie entscheidet nicht über Fairness oder Rechtmäßigkeit.' } },
13
+ { '@type': 'Question', name: 'Warum können Stimmen und Sitze unterschiedliche Summen haben?', acceptedAnswer: { '@type': 'Answer', text: 'Sie sind unterschiedliche Einheiten. Stimmen werden durch die Gesamtstimmen und Sitze durch die Gesamtsitze normiert.' } },
14
+ { '@type': 'Question', name: 'Wie lese ich das Stimmen-Sitz-Verhältnis?', acceptedAnswer: { '@type': 'Answer', text: 'Es ist Stimmenanteil geteilt durch Sitzanteil. 1 bedeutet Parität, ein Wert über 1 weniger Sitzanteil und eine Partei ohne Sitze bei vorhandenen Stimmen hat unendliche Quote.' } },
15
+ { '@type': 'Question', name: 'Beweist das Ergebnis ein unfaires Wahlsystem?', acceptedAnswer: { '@type': 'Answer', text: 'Nein. Die Indizes beschreiben nur den Abstand zweier Verteilungen. Wahlkreisgröße, Sperrklauseln, Wahlbeteiligung und Nenner müssen zusätzlich geprüft werden.' } },
16
+ ] };
17
+ const howTo: HowTo = { '@type': 'HowTo', name: 'Abweichung zwischen Stimmen und Sitzen messen', step: [
18
+ { '@type': 'HowToStep', name: 'Parteien eingeben', text: 'Füge für jede Partei oder Liste eine Zeile hinzu.' }, { '@type': 'HowToStep', name: 'Stimmen und Sitze eingeben', text: 'Verwende denselben Wahlumfang und trage die beobachteten Summen ein.' }, { '@type': 'HowToStep', name: 'Abstand messen', text: 'Starte die Analyse, um beide Spalten zu normieren und drei Indizes zu berechnen.' }, { '@type': 'HowToStep', name: 'Bänder prüfen', text: 'Vergleiche Anteile, Differenz und Stimmen-Sitz-Verhältnis jeder Zeile.' }, { '@type': 'HowToStep', name: 'Kontext prüfen', text: 'Lies die Formeln und kontrolliere die Regeln der betreffenden Wahl.' },
19
+ ] };
20
+
21
+ export const content: ToolLocaleContent<VoteSeatLocaleContent['ui']> = {
22
+ slug: 'analyse-stimmen-sitze-wahlproportionalitaet', title: 'Analyse der Abweichung von Stimmen und Sitzen', description: 'Vergleiche Stimmenanteile und Sitzanteile mit Gallagher-, Loosemore-Hanby- und Sainte-Laguë-Indizes aus transparenten Wahldaten.', ui,
23
+ seo: [
24
+ { type: 'title', text: 'Den Abstand zwischen Stimmen und Sitzen messen', level: 2 },
25
+ { type: 'paragraph', html: 'Eine Wahl kann einer Partei einen deutlich anderen Sitzanteil als Stimmenanteil geben. Diese Analyse macht beide beobachteten Verteilungen vergleichbar und zeigt, welche Parteien relativ zu ihrem Stimmenanteil mehr oder weniger Sitze erhalten. Stimmen und Sitze werden dabei als getrennte Einheiten normiert.' },
26
+ { type: 'paragraph', html: 'Das Ergebnis ist kein Urteil. Ein positiver Abstand bedeutet einen höheren Sitzanteil, ein negativer Abstand einen niedrigeren Sitzanteil. Die Bänder machen diese Richtung sichtbar, bevor die Gesamtindizes betrachtet werden.' },
27
+ { type: 'title', text: 'Was die drei Indizes sichtbar machen', level: 2 },
28
+ { type: 'paragraph', html: 'Der Gallagher-Index zieht die Quadratwurzel aus der halben Summe der quadrierten Abstände in Prozentpunkten. Große Abweichungen zählen dadurch stärker. Der Loosemore-Hanby-Index summiert die absoluten Abstände und halbiert sie.' },
29
+ { type: 'paragraph', html: 'Der Sainte-Laguë-Index verwendet Anteile und teilt das quadrierte Abstandssignal durch den Stimmenanteil jeder Zeile mit Stimmen. Eine Zeile ohne Stimmen kann keinen solchen Nenner liefern und wird deshalb nur bei den beiden anderen Maßen berücksichtigt.' },
30
+ { type: 'table', headers: ['Maß', 'Berechnung', 'Nützliche Frage'], rows: [['Gallagher', 'Wurzel aus der halben Summe quadrierter Prozentpunktabstände.', 'Welche großen Lücken prägen den Abstand?'], ['Loosemore-Hanby', 'Halbe Summe der absoluten Prozentpunktabstände.', 'Wie viel der Verteilung weicht insgesamt ab?'], ['Sainte-Laguë', 'Quadrierter Anteilsabstand geteilt durch den Stimmenanteil.', 'Wie gewichtet der Nenner kleine Parteien?']] },
31
+ { type: 'title', text: 'Vergleichbare Wahldaten vorbereiten', level: 2 },
32
+ { type: 'paragraph', html: 'Nutze denselben Wahlumfang und mische keine nationalen Stimmen mit regionalen Sitzen. Die Summen müssen nicht gleich sein, weil es verschiedene Einheiten sind. Jede Zeile muss aber dieselbe Partei und dasselbe Ergebnis beschreiben.' },
33
+ { type: 'list', items: ['Parteien in beiden Spalten eindeutig benennen.', 'Parteien mit Stimmen, aber ohne Sitze aufnehmen.', 'Nullwerte nur eintragen, wenn sie im Datensatz sinnvoll dokumentiert sind.', 'Prüfen, welche gültigen, ungültigen oder ausgeschlossenen Stimmen in den Nenner gehören.', 'Die Indizes zusammen mit der Quelle der Wahldaten aufbewahren.'] },
34
+ { type: 'tip', title: 'Eine Quote erklärt noch keine Ursache', html: 'Ein Verhältnis über 1 zeigt weniger Sitzanteil als Stimmenanteil. Wahlkreisgröße, Sperrklausel, geografische Konzentration, Wahlbeteiligung und Wahlformel können die Ursache sein.' },
35
+ { type: 'title', text: 'Grenzen vor dem Vergleich kennen', level: 2 },
36
+ { type: 'paragraph', html: 'Niedrigere Werte bedeuten eine geringere Distanz der eingegebenen Verteilungen, nicht automatisch ein besseres Wahlsystem. Vergleiche nur Daten mit gleicher Definition von Stimmen, Sitzen, Parteien, Gebiet und Nenner.' },
37
+ { type: 'paragraph', html: 'Die Analyse schließt keine Ideologie, Absicht, Absprache, Rechtmäßigkeit oder demokratische Legitimität. Sie korrigiert kein amtliches Ergebnis und rekonstruiert keine länderspezifische Auszählung.' },
38
+ { type: 'tip', title: 'Mit dem größten Band beginnen', html: 'Prüfe zuerst die Partei mit der größten Lücke. Vergleiche ihre Rohwerte und kontrolliere danach, ob alle anderen Zeilen gleich behandelt wurden.' },
39
+ ],
40
+ faq: [{ question: 'Was misst die Analyse?', answer: 'Sie vergleicht Stimmen- und Sitzanteile und fasst den Abstand mit Gallagher, Loosemore-Hanby und Sainte-Laguë zusammen.' }, { question: 'Warum können die Summen unterschiedlich sein?', answer: 'Stimmen und Sitze sind verschiedene Einheiten und werden jeweils mit ihrer eigenen Summe normiert.' }, { question: 'Wie lese ich das Verhältnis?', answer: 'Stimmenanteil geteilt durch Sitzanteil: 1 bedeutet Parität, über 1 weniger Sitzanteil.' }, { question: 'Beweist das Ergebnis Unfairness?', answer: 'Nein. Es beschreibt nur einen Abstand und braucht den Kontext der Wahlregeln.' }],
41
+ bibliography, howTo: [{ name: 'Parteien eingeben', text: 'Füge für jede Partei oder Liste eine Zeile hinzu.' }, { name: 'Werte eingeben', text: 'Trage Stimmen und Sitze desselben Wahlumfangs ein.' }, { name: 'Abstand messen', text: 'Starte die Analyse und berechne die Indizes.' }, { name: 'Bänder prüfen', text: 'Vergleiche die Anteile und ihre Differenz.' }, { name: 'Kontext prüfen', text: 'Kontrolliere die geltenden Wahlregeln.' }], schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[],
42
+ };
43
+
44
+ export const germanContent: VoteSeatLocaleContent = content;