@jjlmoya/utils-civic 1.7.0 → 1.8.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -0
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tests/translation_copy.test.ts +1 -1
  7. package/src/tool/parliamentary-voting-analyzer/bibliography.astro +14 -0
  8. package/src/tool/parliamentary-voting-analyzer/bibliography.ts +6 -0
  9. package/src/tool/parliamentary-voting-analyzer/component.astro +63 -0
  10. package/src/tool/parliamentary-voting-analyzer/contract.test.ts +16 -0
  11. package/src/tool/parliamentary-voting-analyzer/controller.ts +65 -0
  12. package/src/tool/parliamentary-voting-analyzer/dom-views.ts +63 -0
  13. package/src/tool/parliamentary-voting-analyzer/entry.ts +27 -0
  14. package/src/tool/parliamentary-voting-analyzer/evaluator.ts +12 -0
  15. package/src/tool/parliamentary-voting-analyzer/i18n/de.ts +45 -0
  16. package/src/tool/parliamentary-voting-analyzer/i18n/en.ts +79 -0
  17. package/src/tool/parliamentary-voting-analyzer/i18n/es.ts +49 -0
  18. package/src/tool/parliamentary-voting-analyzer/i18n/fr.ts +43 -0
  19. package/src/tool/parliamentary-voting-analyzer/i18n/id.ts +45 -0
  20. package/src/tool/parliamentary-voting-analyzer/i18n/it.ts +45 -0
  21. package/src/tool/parliamentary-voting-analyzer/i18n/ja.ts +45 -0
  22. package/src/tool/parliamentary-voting-analyzer/i18n/ko.ts +45 -0
  23. package/src/tool/parliamentary-voting-analyzer/i18n/nl.ts +45 -0
  24. package/src/tool/parliamentary-voting-analyzer/i18n/pl.ts +45 -0
  25. package/src/tool/parliamentary-voting-analyzer/i18n/pt.ts +45 -0
  26. package/src/tool/parliamentary-voting-analyzer/i18n/ru.ts +45 -0
  27. package/src/tool/parliamentary-voting-analyzer/i18n/sv.ts +45 -0
  28. package/src/tool/parliamentary-voting-analyzer/i18n/tr.ts +45 -0
  29. package/src/tool/parliamentary-voting-analyzer/i18n/zh.ts +45 -0
  30. package/src/tool/parliamentary-voting-analyzer/index.ts +11 -0
  31. package/src/tool/parliamentary-voting-analyzer/logic.test.ts +52 -0
  32. package/src/tool/parliamentary-voting-analyzer/logic.ts +279 -0
  33. package/src/tool/parliamentary-voting-analyzer/parliamentary-voting-analyzer.css +491 -0
  34. package/src/tool/parliamentary-voting-analyzer/seo.astro +14 -0
  35. package/src/tool/parliamentary-voting-analyzer/storage.ts +25 -0
  36. package/src/tool/parliamentary-voting-analyzer/ui.ts +98 -0
  37. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-civic",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -5,11 +5,12 @@ import { referendumThresholdCalculator } from '../tool/referendum-threshold-calc
5
5
  import { voteSeatDisproportionalityAnalyzer } from '../tool/vote-seat-disproportionality-analyzer/entry';
6
6
  import { participatoryBudgetAllocator } from '../tool/participatory-budget-allocator/entry';
7
7
  import { publicBudgetAnalyzer } from '../tool/public-budget-analyzer/entry';
8
+ import { parliamentaryVotingAnalyzer } from '../tool/parliamentary-voting-analyzer/entry';
8
9
  import type { CategoryLocaleContent, KnownLocale } from '../types';
9
10
 
10
11
  export const civicCategory = {
11
12
  icon: 'mdi:bank-outline',
12
- tools: [civicPriorityMatrix, coalitionMajorityCalculator, electionSeatApportionmentCalculator, referendumThresholdCalculator, voteSeatDisproportionalityAnalyzer, participatoryBudgetAllocator, publicBudgetAnalyzer],
13
+ tools: [civicPriorityMatrix, coalitionMajorityCalculator, electionSeatApportionmentCalculator, referendumThresholdCalculator, voteSeatDisproportionalityAnalyzer, participatoryBudgetAllocator, publicBudgetAnalyzer, parliamentaryVotingAnalyzer],
13
14
  i18n: {
14
15
  en: () => import('./i18n/en').then((m) => m.content),
15
16
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -13,6 +13,7 @@ import { referendumThresholdCalculator } from './tool/referendum-threshold-calcu
13
13
  import { voteSeatDisproportionalityAnalyzer } from './tool/vote-seat-disproportionality-analyzer/entry';
14
14
  import { participatoryBudgetAllocator } from './tool/participatory-budget-allocator/entry';
15
15
  import { publicBudgetAnalyzer } from './tool/public-budget-analyzer/entry';
16
+ import { parliamentaryVotingAnalyzer } from './tool/parliamentary-voting-analyzer/entry';
16
17
 
17
18
  export const ALL_ENTRIES = [
18
19
  coalitionMajorityCalculator,
@@ -22,4 +23,5 @@ export const ALL_ENTRIES = [
22
23
  voteSeatDisproportionalityAnalyzer,
23
24
  participatoryBudgetAllocator,
24
25
  publicBudgetAnalyzer,
26
+ parliamentaryVotingAnalyzer,
25
27
  ];
@@ -18,6 +18,6 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
 
20
20
  it('all tools registered', () => {
21
- expect(ALL_TOOLS.length).toBe(7);
21
+ expect(ALL_TOOLS.length).toBe(8);
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(7);
8
+ expect(ALL_TOOLS.length).toBe(8);
9
9
  });
10
10
 
11
11
  it('civicCategory should be defined', () => {
@@ -94,7 +94,7 @@ function copySimilarity(left: string, right: string): number {
94
94
  async function loadCorpora(entry: (typeof ALL_ENTRIES)[number]): Promise<Map<string, string>> {
95
95
  const corpora = new Map<string, string>();
96
96
  for (const [locale, loader] of Object.entries(entry.i18n)) {
97
- if (loader) corpora.set(locale, localeCorpus(await loader()));
97
+ if (typeof loader === 'function') corpora.set(locale, localeCorpus(await loader()));
98
98
  }
99
99
  return corpora;
100
100
  }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import Bibliography from '@jjlmoya/utils-shared/ui/Bibliography.astro';
3
+ import { parliamentaryVotingAnalyzer } from './entry';
4
+
5
+ interface Props {
6
+ locale?: string;
7
+ }
8
+
9
+ const { locale = 'en' } = Astro.props;
10
+ const loader = parliamentaryVotingAnalyzer.i18n[locale as keyof typeof parliamentaryVotingAnalyzer.i18n] ?? parliamentaryVotingAnalyzer.i18n.en;
11
+ const content = await loader!();
12
+ ---
13
+
14
+ <Bibliography links={content.bibliography} />
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Cambridge University Press, CongressBR: An R Package for Analyzing Data from Brazil\'s Chamber of Deputies and Federal Senate', url: 'https://www.cambridge.org/core/journals/latin-american-research-review/article/congressbr-an-r-package-for-analyzing-data-from-brazils-chamber-of-deputies-and-federal-senate/697BB8D9ADD0DC4E42DD0D6EC58CB26D' },
5
+ { name: 'Assemblée nationale, Les votes à l\'Assemblée nationale', url: 'https://questions.assemblee-nationale.fr/synthese/fonctionnement-assemblee-nationale/travail-legislatif/les-votes-a-l-assemblee-nationale' },
6
+ ];
@@ -0,0 +1,63 @@
1
+ ---
2
+ import type { ParliamentaryVotingUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: ParliamentaryVotingUI;
6
+ locale?: string;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <section class="parliamentary-voting-analyzer" data-parliamentary-voting-analyzer>
13
+ <div class="pv-input-zone">
14
+ <div class="pv-input-copy">
15
+ <p class="pv-eyebrow">{ui.dataHeading}</p>
16
+ <p>{ui.dataHelp}</p>
17
+ </div>
18
+ <label class="pv-field-label" for="pv-roll-call">{ui.csvLabel}</label>
19
+ <p class="pv-csv-hint">{ui.csvHint}</p>
20
+ <textarea id="pv-roll-call" rows="8" spellcheck="false"></textarea>
21
+ <div class="pv-actions">
22
+ <button type="button" class="pv-primary-action" data-analyze>{ui.analyzeAction}</button>
23
+ <button type="button" class="pv-secondary-action" data-example>{ui.loadExample}</button>
24
+ <button type="button" class="pv-secondary-action" data-clear>{ui.clearAction}</button>
25
+ </div>
26
+ <p class="pv-feedback" data-feedback aria-live="polite"></p>
27
+ </div>
28
+ <div class="pv-mode-zone">
29
+ <fieldset>
30
+ <legend>{ui.compareLabel}</legend>
31
+ <label><input type="radio" name="pv-compare" value="groups" checked /> <span>{ui.groupsOption}</span></label>
32
+ <label><input type="radio" name="pv-compare" value="members" /> <span>{ui.membersOption}</span></label>
33
+ </fieldset>
34
+ <div class="pv-method-strip">
35
+ <span class="pv-method-line" aria-hidden="true"></span>
36
+ <span>{ui.cohesionHelp}</span>
37
+ </div>
38
+ </div>
39
+ <div data-result>
40
+ <div class="pv-empty">
41
+ <div class="pv-empty-mark" aria-hidden="true"></div>
42
+ <p>{ui.emptyResult}</p>
43
+ </div>
44
+ </div>
45
+ <div class="pv-note-zone">
46
+ <div><h3>{ui.methodHeading}</h3><p>{ui.methodText}</p></div>
47
+ <div><h3>{ui.limitsHeading}</h3><p>{ui.limitsText}</p></div>
48
+ <div><h3>{ui.edgeHeading}</h3><p>{ui.edgeText}</p></div>
49
+ </div>
50
+ </section>
51
+
52
+ <script is:inline type="application/json" data-pv-data set:html={JSON.stringify({ ui })}></script>
53
+ <script>
54
+ import { initParliamentaryVotingAnalyzer } from './controller';
55
+ import type { ParliamentaryVotingUI } from './ui';
56
+
57
+ const payload = document.querySelector('script[data-pv-data]');
58
+ const root = document.querySelector('[data-parliamentary-voting-analyzer]');
59
+ if (payload instanceof HTMLScriptElement && root instanceof HTMLElement) {
60
+ const data = JSON.parse(payload.textContent ?? '{}') as { ui: ParliamentaryVotingUI };
61
+ initParliamentaryVotingAnalyzer(root, data.ui);
62
+ }
63
+ </script>
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parliamentaryVotingAnalyzer } from './entry';
3
+ import { PARLIAMENTARY_VOTING_ANALYZER_TOOL } from './index';
4
+
5
+ describe('parliamentary voting analyzer runtime contract', () => {
6
+ it('exposes the English content and the three page component loaders', async () => {
7
+ const content = await parliamentaryVotingAnalyzer.i18n.en?.();
8
+ expect(content?.slug).toBe('parliamentary-voting-analyzer');
9
+ expect(content?.ui).toBeDefined();
10
+ expect(content?.seo.length).toBeGreaterThan(0);
11
+ expect(content?.schemas.length).toBe(3);
12
+ expect(typeof PARLIAMENTARY_VOTING_ANALYZER_TOOL.Component).toBe('function');
13
+ expect(typeof PARLIAMENTARY_VOTING_ANALYZER_TOOL.SEOComponent).toBe('function');
14
+ expect(typeof PARLIAMENTARY_VOTING_ANALYZER_TOOL.BibliographyComponent).toBe('function');
15
+ });
16
+ });
@@ -0,0 +1,65 @@
1
+ import { analyzeRollCall, parseRollCall } from './logic';
2
+ import { buildCopyText, renderAnalysis } from './dom-views';
3
+ import { clearDraft, loadDraft, saveDraft } from './storage';
4
+ import type { ParliamentaryVotingUI } from './ui';
5
+
6
+ const exampleCsv = 'member,group,vote,choice\nAmina Rahal,Harbor Group,Bill 1,yes\nLuis Chen,Harbor Group,Bill 1,yes\nMarta Silva,Harbor Group,Bill 1,no\nAmina Rahal,Harbor Group,Bill 2,abstain\nLuis Chen,Harbor Group,Bill 2,yes\nMarta Silva,Harbor Group,Bill 2,yes\nOwen Price,Civic List,Bill 1,no\nNora Iqbal,Civic List,Bill 1,no\nOwen Price,Civic List,Bill 2,absent\nNora Iqbal,Civic List,Bill 2,yes\nSofia Marin,Bridge Group,Bill 1,unknown\nSofia Marin,Bridge Group,Bill 2,yes';
7
+
8
+ const query = <T extends Element>(root: ParentNode, selector: string): T | null => root.querySelector<T>(selector);
9
+
10
+ const readMode = (root: ParentNode): 'members' | 'groups' => query<HTMLInputElement>(root, 'input[name="pv-compare"]:checked')?.value === 'members' ? 'members' : 'groups';
11
+
12
+ const update = (root: HTMLElement, ui: ParliamentaryVotingUI): void => {
13
+ const textarea = query<HTMLTextAreaElement>(root, 'textarea');
14
+ const result = query<HTMLElement>(root, '[data-result]');
15
+ if (!textarea || !result) return;
16
+ const analysis = analyzeRollCall(parseRollCall(textarea.value));
17
+ const emptyMessage = textarea.value.trim() ? ui.invalidCsvMessage : ui.emptyResult;
18
+ result.innerHTML = analysis.records.length ? renderAnalysis(analysis, readMode(root), ui) : `<div class="pv-empty"><div class="pv-empty-mark" aria-hidden="true"></div><p>${emptyMessage}</p></div>`;
19
+ root.dataset.copyText = buildCopyText(analysis, readMode(root));
20
+ };
21
+
22
+ const copySummary = async (root: HTMLElement, ui: ParliamentaryVotingUI): Promise<void> => {
23
+ const text = root.dataset.copyText ?? '';
24
+ const feedback = query<HTMLElement>(root, '[data-feedback]');
25
+ if (!text || !feedback) return;
26
+ try {
27
+ await navigator.clipboard.writeText(text);
28
+ feedback.textContent = ui.copiedMessage;
29
+ } catch {
30
+ feedback.textContent = ui.copyFailure;
31
+ }
32
+ };
33
+
34
+ const handleExample = (root: HTMLElement, textarea: HTMLTextAreaElement, ui: ParliamentaryVotingUI): void => {
35
+ textarea.value = exampleCsv;
36
+ saveDraft(exampleCsv);
37
+ const feedback = query<HTMLElement>(root, '[data-feedback]');
38
+ if (feedback) feedback.textContent = ui.importedMessage;
39
+ update(root, ui);
40
+ };
41
+
42
+ const handleClick = (root: HTMLElement, textarea: HTMLTextAreaElement, ui: ParliamentaryVotingUI, event: Event): void => {
43
+ const target = event.target as HTMLElement;
44
+ if (target.closest('[data-example]')) handleExample(root, textarea, ui);
45
+ if (target.closest('[data-clear]')) {
46
+ textarea.value = '';
47
+ clearDraft();
48
+ update(root, ui);
49
+ }
50
+ if (target.closest('[data-analyze]')) update(root, ui);
51
+ if (target.closest('[data-copy-summary]')) void copySummary(root, ui);
52
+ };
53
+
54
+ export const initParliamentaryVotingAnalyzer = (root: HTMLElement, ui: ParliamentaryVotingUI): void => {
55
+ const textarea = query<HTMLTextAreaElement>(root, 'textarea');
56
+ if (!textarea) return;
57
+ const draft = loadDraft();
58
+ if (draft) textarea.value = draft;
59
+ update(root, ui);
60
+ root.addEventListener('input', (event) => {
61
+ if (event.target === textarea) saveDraft(textarea.value);
62
+ });
63
+ root.addEventListener('change', () => update(root, ui));
64
+ root.addEventListener('click', (event) => handleClick(root, textarea, ui, event));
65
+ };
@@ -0,0 +1,63 @@
1
+ import type { AnalysisResult, GroupVoteResult, PairwiseResult } from './logic';
2
+ import type { ParliamentaryVotingUI } from './ui';
3
+
4
+ const escapeHtml = (value: string): string => value.replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character] ?? character));
5
+
6
+ const formatRate = (value: number | null): string => value === null ? 'Not available' : `${Math.round(value * 100)}%`;
7
+
8
+ const choiceClass = (item: GroupVoteResult): string => {
9
+ if (item.yes === item.no && item.comparable > 0) return 'is-tie';
10
+ if (item.yes > item.no) return 'is-yes';
11
+ if (item.no > item.yes) return 'is-no';
12
+ if (item.abstain > 0) return 'is-abstain';
13
+ return 'is-quiet';
14
+ };
15
+
16
+ const renderRibbon = (result: AnalysisResult, ui: ParliamentaryVotingUI): string => {
17
+ const votes = [...new Set(result.groupVotes.map((item) => item.vote))];
18
+ const rows = result.summaries.map((summary) => {
19
+ const cells = votes.map((vote) => {
20
+ const item = result.groupVotes.find((candidate) => candidate.group === summary.group && candidate.vote === vote);
21
+ return item ? `<span class="pv-ribbon-cell ${choiceClass(item)}" title="${escapeHtml(vote)}: ${item.yes} yes, ${item.no} no" aria-label="${escapeHtml(vote)} ${item.yes} yes ${item.no} no"></span>` : '<span class="pv-ribbon-cell is-missing" aria-label="No record"></span>';
22
+ }).join('');
23
+ return `<div class="pv-ribbon-row"><span class="pv-ribbon-name">${escapeHtml(summary.group)}</span><span class="pv-ribbon-track">${cells}</span><span class="pv-ribbon-score">${formatRate(summary.meanRice)}</span></div>`;
24
+ }).join('');
25
+ const labels = votes.map((vote) => `<span>${escapeHtml(vote)}</span>`).join('');
26
+ return `<div class="pv-ribbon-head"><span>${escapeHtml(ui.groupLabel)}</span><span><small>${escapeHtml(ui.voteLabel)}</small><span class="pv-ribbon-votes">${labels}</span></span><span>${escapeHtml(ui.riceLabel)}</span></div>${rows}`;
27
+ };
28
+
29
+ const renderSummary = (result: AnalysisResult, ui: ParliamentaryVotingUI): string => result.summaries.map((summary) => `<div class="pv-summary-line"><strong>${escapeHtml(summary.group)}</strong><span>${formatRate(summary.meanRice)} ${escapeHtml(ui.riceLabel)}</span><span>${summary.comparableVotes}/${summary.votes} ${escapeHtml(ui.comparableLabel)}</span><span>${escapeHtml(ui.participationLabel)}: ${summary.yes} yes · ${summary.no} no · ${summary.abstain} ${escapeHtml(ui.abstentionsLabel)} · ${summary.absent} ${escapeHtml(ui.absencesLabel)}</span></div>`).join('');
30
+
31
+ const renderPair = (item: PairwiseResult): string => `<tr><th scope="row">${escapeHtml(item.left)}</th><td>${escapeHtml(item.right)}</td><td>${formatRate(item.agreementRate)}</td><td>${item.agreements}/${item.comparableVotes}</td></tr>`;
32
+
33
+ const hasLowSample = (result: AnalysisResult): boolean => result.memberAgreement.some((item) => item.comparableVotes < 2) || result.groupAgreement.some((item) => item.comparableVotes < 2);
34
+
35
+ const hasGroupTie = (result: AnalysisResult): boolean => result.groupAgreement.some((item) => item.comparableVotes === 0);
36
+
37
+ const hasVoteTie = (result: AnalysisResult): boolean => result.groupVotes.some((item) => item.yes === item.no && item.comparable > 0);
38
+
39
+ const hasAnyTie = (result: AnalysisResult): boolean => hasGroupTie(result) || hasVoteTie(result);
40
+
41
+ const renderWarnings = (result: AnalysisResult, ui: ParliamentaryVotingUI): string => {
42
+ const warnings: string[] = [];
43
+ if (!result.records.length) warnings.push(ui.noDataWarning);
44
+ if (result.skippedRows) warnings.push(`${ui.malformedRowWarning} ${result.skippedRows}`);
45
+ if (result.duplicateRecords) warnings.push(`${ui.duplicateWarning} ${result.duplicateRecords}`);
46
+ if (result.unknownChoices) warnings.push(`${ui.unknownChoiceWarning} ${result.unknownChoices} ${ui.unknownLabel}`);
47
+ if (hasAnyTie(result)) warnings.push(ui.tieWarning);
48
+ if (hasLowSample(result)) warnings.push(ui.lowSampleWarning);
49
+ return warnings.length ? `<div class="pv-warnings" role="status"><h3>${escapeHtml(ui.warningsHeading)}</h3><ul>${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul></div>` : '';
50
+ };
51
+
52
+ export const renderAnalysis = (result: AnalysisResult, mode: 'members' | 'groups', ui: ParliamentaryVotingUI): string => {
53
+ const pairs = mode === 'members' ? result.memberAgreement : result.groupAgreement;
54
+ const pairRows = pairs.length ? pairs.map(renderPair).join('') : `<tr><td colspan="4">${escapeHtml(ui.noComparableData)}</td></tr>`;
55
+ return `<section class="pv-result" aria-live="polite"><div class="pv-result-intro"><div><p class="pv-eyebrow">${escapeHtml(ui.resultHeading)}</p><p class="pv-countline"><strong>${result.records.length}</strong> ${escapeHtml(ui.rowsLabel)} <strong>${result.groupCount}</strong> ${escapeHtml(ui.groupsLabel)} <strong>${result.voteCount}</strong> ${escapeHtml(ui.votesLabel)}</p></div><button type="button" class="pv-quiet-action" data-copy-summary>${escapeHtml(ui.copyAction)}</button></div><div class="pv-ribbon" aria-label="${escapeHtml(ui.cohesionHeading)}">${renderRibbon(result, ui)}</div><p class="pv-legend"><span class="pv-dot is-yes"></span> yes <span class="pv-dot is-no"></span> no <span class="pv-dot is-abstain"></span> abstention <span class="pv-dot is-quiet"></span> absence or unknown</p><div class="pv-result-detail"><div><h3>${escapeHtml(ui.cohesionHeading)}</h3><p>${escapeHtml(ui.cohesionHelp)}</p><div class="pv-summary-list">${renderSummary(result, ui)}</div></div><div><h3>${escapeHtml(ui.agreementHeading)}</h3><p>${escapeHtml(ui.agreementHelp)}</p><div class="pv-table-wrap"><table><thead><tr><th>${escapeHtml(ui.groupLabel)}</th><th>${escapeHtml(ui.compareLabel)}</th><th>${escapeHtml(ui.agreementHeading)}</th><th>${escapeHtml(ui.comparableLabel)}</th></tr></thead><tbody>${pairRows}</tbody></table></div></div></div>${renderWarnings(result, ui)}</section>`;
56
+ };
57
+
58
+ export const buildCopyText = (result: AnalysisResult, mode: 'members' | 'groups'): string => {
59
+ const pairs = mode === 'members' ? result.memberAgreement : result.groupAgreement;
60
+ const summary = result.summaries.map((item) => `${item.group}: Rice ${formatRate(item.meanRice)} across ${item.comparableVotes}/${item.votes} comparable votes`).join('\n');
61
+ const agreement = pairs.map((item) => `${item.left} and ${item.right}: ${formatRate(item.agreementRate)} across ${item.comparableVotes} comparable votes`).join('\n');
62
+ return `Parliamentary voting analysis\n${summary}\n\nPairwise agreement\n${agreement}`;
63
+ };
@@ -0,0 +1,27 @@
1
+ import type { CivicToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { ParliamentaryVotingUI } from './ui';
3
+
4
+ export type ParliamentaryVotingLocaleContent = ToolLocaleContent<ParliamentaryVotingUI>;
5
+
6
+ export const parliamentaryVotingAnalyzer: CivicToolEntry<ParliamentaryVotingUI> = {
7
+ id: 'parliamentary-voting-analyzer',
8
+ phase: 'localized',
9
+ icons: { bg: 'mdi:vote-outline', fg: 'mdi:chart-timeline-variant' },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((module) => module.content),
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
@@ -0,0 +1,12 @@
1
+ import type { AnalysisResult } from './logic';
2
+
3
+ export interface AnalysisStatus {
4
+ tone: 'empty' | 'ready' | 'warning';
5
+ message: string;
6
+ }
7
+
8
+ export const evaluateAnalysis = (result: AnalysisResult, emptyMessage: string): AnalysisStatus => {
9
+ if (!result.records.length) return { tone: 'empty', message: emptyMessage };
10
+ if (!result.groupVotes.some((item) => item.rice !== null)) return { tone: 'warning', message: 'The data has no comparable yes or no choices for a Rice Index.' };
11
+ return { tone: 'ready', message: 'The record has enough binary choices to inspect cohesion.' };
12
+ };
@@ -0,0 +1,45 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { ParliamentaryVotingLocaleContent } from '../entry';
4
+ import type { ParliamentaryVotingUI } from '../ui';
5
+ import type { ToolLocaleContent } from '../../../types';
6
+
7
+ const ui: ParliamentaryVotingUI = {
8
+ dataHeading: 'Eine namentliche Abstimmung sichtbar machen', dataHelp: 'Füge eine Zeile pro Mitglied und Abstimmung ein. Verwende die vier Spalten Mitglied, Gruppe, Abstimmung und Entscheidung.', csvLabel: 'CSV der namentlichen Abstimmung', csvHint: 'Pflichtspalten: Mitglied, Gruppe, Abstimmung, Entscheidung. Erlaubt: Ja, Nein, Enthaltung, abwesend, unbekannt.', loadExample: 'Beispiel laden', analyzeAction: 'Abstimmungen analysieren', clearAction: 'Daten löschen', compareLabel: 'Übereinstimmungsansicht', groupsOption: 'Gruppen', membersOption: 'Mitglieder', resultHeading: 'Das Abstimmungsprotokoll auf einen Blick', emptyResult: 'Füge eine Abstimmung ein oder lade das Beispiel, um das Abstimmungsband zu sehen.', rowsLabel: 'Einträge', groupsLabel: 'Gruppen', votesLabel: 'Abstimmungen', warningsHeading: 'Prüfungen vor der Interpretation', methodHeading: 'Angewandte Methode', methodText: 'Der Rice-Index für jede Gruppe und Abstimmung ist die absolute Differenz zwischen Ja und Nein geteilt durch Ja plus Nein. Enthaltungen, Abwesenheiten und unbekannte Werte werden aus diesem binären Nenner ausgeschlossen. Gruppenmittel sind ungewichtete Mittelwerte der Abstimmungen mit mindestens einem Ja oder Nein. Die Übereinstimmung vergleicht binäre Entscheidungen derselben Abstimmung und zeigt immer die Zahl vergleichbarer Abstimmungen.', limitsHeading: 'Was dieses Werkzeug nicht leistet', limitsText: 'Es leitet keine Ideologie, Absicht, Disziplin, Absprache, Fehlverhalten, politischen Positionen oder Kausalität ab. Es entscheidet auch nicht, ob eine Abstimmung gültig war oder ein Mitglied rechtlich zur Anwesenheit verpflichtet war. Ähnliche Abstimmungen können viele Erklärungen haben.', edgeHeading: 'Grenzfälle und Datenhinweise', edgeText: 'Einstimmige Abstimmungen erhalten den Wert 1, sind aber kein Beweis für Disziplin. Unentschieden ergeben null Zusammenhalt und werden getrennt von Enthaltungen markiert. Fehlende Stimmen verkleinern die Vergleichsmenge. Halte wechselnde Gruppenzugehörigkeiten, doppelte Kennungen und unbekannte Entscheidungen in den Quelldaten sichtbar.', cohesionHeading: 'Zusammenhaltsband', cohesionHelp: 'Jede Zeile ist eine Gruppe. Eine helle Markierung bedeutet Ja, eine dunkle Nein, eine gestrichelte Gleichstand, eine hohle Enthaltung und eine blasse Markierung Abwesenheit oder unbekannte Daten.', agreementHeading: 'Paarweise Übereinstimmung', agreementHelp: 'Die Übereinstimmung nutzt nur vergleichbare Ja- und Nein-Entscheidungen, die beide Entitäten teilen. In der Gruppenansicht steuert jede Gruppe ihre nicht unentschiedene Mehrheitsentscheidung bei. Enthaltungen und Nichtteilnahmen bleiben aus Zähler und Nenner heraus.', groupLabel: 'Gruppe', voteLabel: 'Abstimmung', riceLabel: 'Rice-Index', comparableLabel: 'Vergleichbare Abstimmungen', participationLabel: 'Erfasste Entscheidungen', abstentionsLabel: 'Enthaltungen', absencesLabel: 'Abwesenheiten', unknownLabel: 'unbekannt', noComparableData: 'Keine vergleichbaren Ja- oder Nein-Entscheidungen', noDataWarning: 'Keine nutzbaren Einträge gefunden. Ergänze Werte für Mitglied, Gruppe, Abstimmung und Entscheidung.', malformedRowWarning: 'Einige Zeilen wurden wegen eines leeren Pflichtfelds übersprungen.', duplicateWarning: 'Doppelte Kombinationen aus Mitglied und Abstimmung wurden nur einmal übernommen.', unknownChoiceWarning: 'Einige Entscheidungen blieben unbekannt, weil sie nicht als Ja, Nein, Enthaltung oder Abwesenheit erkannt wurden.', lowSampleWarning: 'Einige Paarvergleiche haben weniger als zwei vergleichbare Abstimmungen und sollten nicht als stabiles Muster gelten.', tieWarning: 'Ein Gleichstand einer Gruppe oder Entität wurde für diese Abstimmung aus dem Gruppenvergleich ausgeschlossen.', importedMessage: 'Beispiel geladen. Du kannst die Zeilen vor der Analyse bearbeiten.', invalidCsvMessage: 'Die Daten konnten nicht als zeilenbasiertes CSV gelesen werden.', copyAction: 'Zusammenfassung kopieren', copiedMessage: 'Zusammenfassung in die Zwischenablage kopiert.', copyFailure: 'Kopieren fehlgeschlagen. Wähle den Zusammenfassungstext manuell aus.',
9
+ };
10
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Analysator parlamentarischer Abstimmungen', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Analysiert Zusammenhalt und paarweise Übereinstimmung parlamentarischer namentlicher Abstimmungen anhand einer transparenten Stimmenmatrix.', url: 'https://gamebob.dev/de/parlamentarische-abstimmungsanalyse', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
11
+ const faqPage: FAQPage = { '@type': 'FAQPage', mainEntity: [
12
+ { '@type': 'Question', name: 'Was misst der Rice-Index?', acceptedAnswer: { '@type': 'Answer', text: 'Für eine Gruppe und eine Abstimmung ist er die absolute Differenz zwischen Ja- und Nein-Stimmen geteilt durch ihre Summe. Er reicht von null bei einer Teilung bis eins bei einer einstimmigen binären Abstimmung.' } },
13
+ { '@type': 'Question', name: 'Werden Enthaltungen und Abwesenheiten berücksichtigt?', acceptedAnswer: { '@type': 'Answer', text: 'Nein. Der binäre Rice-Nenner verwendet nur Ja und Nein. Enthaltungen, Abwesenheiten und unbekannte Werte bleiben als Beteiligungssignale sichtbar, fließen aber nicht in den binären Wert ein.' } },
14
+ { '@type': 'Question', name: 'Beweist Übereinstimmung eine gemeinsame Ideologie?', acceptedAnswer: { '@type': 'Answer', text: 'Nein. Übereinstimmung beschreibt nur die aufgezeichneten binären Entscheidungen in den vergleichbaren Abstimmungen. Sie bestimmt weder Ideologie noch Absicht, Absprache oder Kausalität.' } },
15
+ { '@type': 'Question', name: 'Warum werden vergleichbare Abstimmungen gezählt?', acceptedAnswer: { '@type': 'Answer', text: 'Ein Prozentsatz ohne Nenner kann fehlende Einträge verbergen. Der vergleichbare Wert zeigt, wie viele gemeinsame Abstimmungen tatsächlich in die Übereinstimmungsrate eingingen.' } },
16
+ ] };
17
+ const howTo: HowTo = { '@type': 'HowTo', name: 'Parlamentarische namentliche Abstimmungen analysieren', step: [
18
+ { '@type': 'HowToStep', name: 'Eine Zeile pro Entscheidung vorbereiten', text: 'Erstelle CSV-Zeilen mit den Spalten Mitglied, Gruppe, Abstimmung und Entscheidung. Verwende Ja, Nein, Enthaltung, abwesend oder unbekannt.' },
19
+ { '@type': 'HowToStep', name: 'Die Matrix laden', text: 'Füge das CSV ein oder lade das Beispiel und prüfe, ob Gruppen- und Abstimmungszahlen zur Quelle passen.' },
20
+ { '@type': 'HowToStep', name: 'Gruppenzusammenhalt lesen', text: 'Nutze Band und Rice-Zusammenfassung, um den binären Zusammenhalt jeder Gruppe und Abstimmung zu prüfen.' },
21
+ { '@type': 'HowToStep', name: 'Entscheidungen vergleichen', text: 'Wechsle zwischen Gruppen- und Mitgliedervergleich und lies jede Rate zusammen mit der Zahl vergleichbarer Abstimmungen.' },
22
+ { '@type': 'HowToStep', name: 'Hinweise prüfen', text: 'Untersuche übersprungene Zeilen, Duplikate, unbekannte Entscheidungen, Gleichstände und kleine Stichproben.' },
23
+ ] };
24
+ const faqItems = (faqPage.mainEntity ?? []) as unknown as Array<{ name?: string; acceptedAnswer?: { text?: string } }>;
25
+ const howToSteps = (howTo.step ?? []) as unknown as Array<{ name?: string; text?: string }>;
26
+ const seo: ToolLocaleContent<ParliamentaryVotingUI>['seo'] = [
27
+ { type: 'title', text: 'Zusammenhalt in einer parlamentarischen namentlichen Abstimmung prüfen', level: 2 },
28
+ { type: 'paragraph', html: 'Eine namentliche Abstimmung zeigt, wer abgestimmt hat, aber eine lange Liste von Ja und Nein macht nicht sofort sichtbar, ob eine Gruppe geschlossen handelte. Dieser Analysator verwandelt eine zeilenbasierte Matrix in ein visuelles Abstimmungsband, Gruppenzusammenfassungen und paarweise Übereinstimmungen. Unterschiede im Ausgangsdatensatz bleiben erhalten, damit ein glatter Prozentsatz fehlende oder nicht binäre Entscheidungen nicht verdeckt.' },
29
+ { type: 'paragraph', html: 'Die vorgesehene Eingabe ist eine Tabelle mit einer Zeile für jedes Mitglied und jede Abstimmung. Jede Zeile enthält Mitglied, parlamentarische Gruppe, Abstimmungskennung und aufgezeichnete Entscheidung. Übliche Begriffe wie dafür, dagegen, Enthaltung und nicht abgestimmt werden erkannt; unbekannte Werte bleiben unbekannt, statt stillschweigend umcodiert zu werden.' },
30
+ { type: 'title', text: 'So wird der Rice-Index berechnet', level: 2 },
31
+ { type: 'paragraph', html: 'Für eine Gruppe in einer Abstimmung lautet der Rice-Index |Ja minus Nein| geteilt durch Ja plus Nein. Eine einstimmige binäre Abstimmung ergibt 1, eine gleichmäßige Teilung 0, und eine Gruppe ohne Ja oder Nein erhält keinen Rice-Wert. Die Gruppenzusammenfassung ist der ungewichtete Mittelwert verfügbarer Abstimmungswerte; fehlende binäre Angaben werden daher nicht als erfundene Nullen behandelt.' },
32
+ { type: 'table', headers: ['Signal', 'Berechnung', 'Bedeutung'], rows: [['Rice-Index', '|Ja minus Nein| / (Ja plus Nein)', 'Binärer Zusammenhalt von Gruppe und Abstimmung'], ['Mittlerer Rice', 'Mittelwert verfügbarer Abstimmungswerte', 'Typischer Zusammenhalt vergleichbarer Abstimmungen'], ['Übereinstimmung', 'Gleiche binäre Entscheidungen / vergleichbare gemeinsame Abstimmungen', 'Beobachtete Übereinstimmung zweier Entitäten']] },
33
+ { type: 'title', text: 'Eine überprüfbare Matrix vorbereiten', level: 2 },
34
+ { type: 'paragraph', html: 'Verwende für jede namentliche Abstimmung eine stabile Kennung und behalte die ursprünglichen Entscheidungskategorien bis nach dem Import. Wandle Abwesenheit nicht in Nein und Enthaltung nicht in eine leere Zelle um. Solche Entscheidungen verändern Beteiligungszahlen und die Menge vergleichbarer Beobachtungen.' },
35
+ { type: 'list', items: ['Bestätige, dass jede Zeile zum untersuchten Parlament, Zeitraum und Gremium gehört.', 'Schreibe Mitglieder- und Gruppennamen über alle Abstimmungen gleich, einschließlich Akzenten und Satzzeichen.', 'Vergib für getrennte namentliche Abstimmungen zu wiederholten Anträgen verschiedene Kennungen.', 'Prüfe doppelte Mitglied-Abstimmung-Paare anhand des Originals, bevor du die erste Zeile akzeptierst.', 'Bewahre Quelldatei und exakt importierten Text neben jeder veröffentlichten Interpretation auf.'] },
36
+ { type: 'tip', title: 'Ein Prozentsatz braucht seinen Nenner', html: 'Eine paarweise Übereinstimmung von 100 % bei zwei gemeinsamen Abstimmungen ist eine andere Evidenz als 100 % bei achtzig. Lies die vergleichbare Zahl neben jeder Rate und behandle kleine Stichproben als Anlass für mehr Daten, nicht als stabiles Muster.' },
37
+ { type: 'title', text: 'Das Band ohne Überinterpretation lesen', level: 2 },
38
+ { type: 'paragraph', html: 'Das Band ist eine Lesehilfe, kein politisches Urteil. Blaue Markierungen zeigen eine Gruppenmehrheit von Ja, rostfarbene eine Nein-Mehrheit, Umrisse eine Enthaltung und ruhige Markierungen Abwesenheit oder unbekannte Daten. Der Wert neben der Zeile fasst nur binäre Entscheidungen zusammen; Beteiligungszahlen zeigen, was aus dem Nenner ausgeschlossen wurde.' },
39
+ { type: 'paragraph', html: 'Hoher Zusammenhalt kann aus gemeinsamen Präferenzen, Parteidisziplin, Koalitionsverhandlungen, einem eng gefassten Antrag oder einer kleinen Gruppe entstehen. Hohe Übereinstimmung zweier Gruppen kann eine Tagesordnung mit wenig Streit widerspiegeln. Ein niedriger Wert kann echte Spaltung oder wechselnde Mitgliedsdaten bedeuten. Der Analysator beschreibt die Matrix und entscheidet nicht zwischen diesen Erklärungen.' },
40
+ { type: 'title', text: 'Methodische Grenzen und Datenhinweise', level: 2 },
41
+ { type: 'paragraph', html: 'Das Werkzeug verwendet die konventionelle binäre Rice-Berechnung und eine transparente Regel für paarweise Übereinstimmung. Es leitet weder Ideologie, Absicht, Fehlverhalten noch rechtliche Pflichten ab. Prüfe offizielle Definitionen der namentlichen Abstimmung, bevor du Enthaltung, Abwesenheit oder unbekannte Werte zwischen Parlamenten vergleichst.' },
42
+ { type: 'tip', title: 'Hinweise gehören zum Ergebnis', html: 'Übersprungene Zeilen, doppelte Kennungen, unbekannte Entscheidungen, Gleichstände und kleine vergleichbare Stichproben sind keine kosmetischen Meldungen. Kläre sie oder dokumentiere sie, bevor du Zeiträume, Gruppen oder Mitglieder vergleichst.' },
43
+ ];
44
+
45
+ export const content: ParliamentaryVotingLocaleContent = { slug: 'parlamentarische-abstimmungsanalyse', title: 'Analysator parlamentarischer Abstimmungen', description: 'Miss Gruppenzusammenhalt und paarweise Übereinstimmung aus einer Matrix namentlicher Abstimmungen und halte Enthaltungen, Abwesenheiten, Nenner und Warnungen sichtbar.', ui, seo, faq: faqItems.map((item) => ({ question: String(item.name), answer: String(item.acceptedAnswer?.text) })), bibliography, howTo: howToSteps.map((step) => ({ name: String(step.name), text: String(step.text) })), schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[] };
@@ -0,0 +1,79 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliography } from '../bibliography';
4
+ import type { ParliamentaryVotingLocaleContent } from '../entry';
5
+ import { ui } from '../ui';
6
+
7
+ const softwareApplication: SoftwareApplication = {
8
+ '@type': 'SoftwareApplication',
9
+ name: 'Parliamentary Voting Analyzer',
10
+ applicationCategory: 'EducationalApplication',
11
+ operatingSystem: 'Any',
12
+ description: 'Analyze parliamentary roll call cohesion and pairwise agreement from a transparent vote matrix.',
13
+ url: 'https://gamebob.dev/en/parliamentary-voting-analyzer',
14
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
15
+ };
16
+
17
+ const faqPage: FAQPage = {
18
+ '@type': 'FAQPage',
19
+ mainEntity: [
20
+ { '@type': 'Question', name: 'What does the Rice Index measure?', acceptedAnswer: { '@type': 'Answer', text: 'For one group and one vote, it is the absolute difference between yes and no votes divided by their sum. It ranges from zero for an even split to one for a unanimous binary vote.' } },
21
+ { '@type': 'Question', name: 'Are abstentions and absences included?', acceptedAnswer: { '@type': 'Answer', text: 'No. The binary Rice denominator uses only yes and no choices. Abstentions, absences, and unknown values remain visible as participation signals and are excluded from the binary score.' } },
22
+ { '@type': 'Question', name: 'Can agreement prove that two groups share an ideology?', acceptedAnswer: { '@type': 'Answer', text: 'No. Agreement only describes the recorded binary choices on the votes that both entities can be compared on. It does not identify ideology, intent, collusion, or causation.' } },
23
+ { '@type': 'Question', name: 'Why does the analyzer show comparable vote counts?', acceptedAnswer: { '@type': 'Answer', text: 'A percentage without its denominator can hide missing records. The comparable count shows how many shared votes actually contributed to each agreement rate.' } },
24
+ ],
25
+ };
26
+
27
+ const howTo: HowTo = {
28
+ '@type': 'HowTo',
29
+ name: 'Analyze parliamentary roll call votes',
30
+ step: [
31
+ { '@type': 'HowToStep', name: 'Prepare one row per choice', text: 'Create CSV rows with member, group, vote, and choice columns. Use yes, no, abstain, absent, or unknown for the choice.' },
32
+ { '@type': 'HowToStep', name: 'Load the matrix', text: 'Paste the CSV into the analyzer or load the example, then check that the group and vote counts match your source.' },
33
+ { '@type': 'HowToStep', name: 'Read group cohesion', text: 'Use the ribbon and Rice Index summary to inspect binary cohesion for each group and each vote.' },
34
+ { '@type': 'HowToStep', name: 'Compare choices', text: 'Switch between group and member agreement, and read every rate together with its comparable vote count.' },
35
+ { '@type': 'HowToStep', name: 'Review warnings', text: 'Investigate skipped rows, duplicates, unknown choices, ties, and low samples before drawing a conclusion.' },
36
+ ],
37
+ };
38
+
39
+ export const content: ToolLocaleContent<ParliamentaryVotingLocaleContent['ui']> = {
40
+ slug: 'parliamentary-voting-analyzer',
41
+ title: 'Parliamentary Voting Analyzer',
42
+ description: 'Measure group cohesion and pairwise voting agreement from a roll call matrix while keeping abstentions, absences, denominators, and warnings visible.',
43
+ ui,
44
+ seo: [
45
+ { type: 'title', text: 'Inspect Cohesion in a Parliamentary Roll Call', level: 2 },
46
+ { type: 'paragraph', html: 'A roll call records who voted, but a long list of yes and no choices does not immediately show whether a group moved together. This analyzer turns a row based matrix into a visual voting ribbon, group summaries, and pairwise agreement results. It keeps the source distinctions visible so that a neat percentage does not erase missing or nonbinary choices.' },
47
+ { type: 'paragraph', html: 'The intended input is a table with one row for each member and vote. Each row names the member, parliamentary group, vote identifier, and recorded choice. The parser accepts common labels such as aye, nay, abstention, and no vote, but an unrecognized value is retained as unknown rather than silently recoded.' },
48
+ { type: 'title', text: 'How the Rice Index Is Calculated', level: 2 },
49
+ { type: 'paragraph', html: 'For one group on one vote, the Rice Index is |yes minus no| divided by yes plus no. A unanimous binary vote is 1, an even split is 0, and a group with no yes or no choice has no Rice value. The group summary is the unweighted mean of the available vote level scores, so a group with missing binary choices does not receive an invented zero.' },
50
+ { type: 'table', headers: ['Signal', 'Calculation', 'Interpretation'], rows: [['Rice Index', '|yes minus no| / (yes plus no)', 'Binary cohesion for one group and vote'], ['Mean Rice', 'Mean of available vote scores', 'Typical cohesion across comparable votes'], ['Agreement', 'Matching binary choices / comparable shared votes', 'Observed coincidence between two entities']] },
51
+ { type: 'title', text: 'Prepare a Matrix That Can Be Checked', level: 2 },
52
+ { type: 'paragraph', html: 'Use one stable vote identifier for every roll call and keep the original choice categories until after import. Do not turn absence into no, and do not turn abstention into a missing blank cell. Those decisions alter both participation counts and the set of observations that can be compared.' },
53
+ { type: 'list', items: ['Confirm that every row belongs to the same chamber and period you intend to study.', 'Keep member and group names consistent across votes, including accents and punctuation.', 'Give repeated motions distinct identifiers when they are separate roll calls.', 'Check duplicate member and vote pairs against the original record before accepting the first row.', 'Save the source file and the exact imported text beside any published interpretation.'] },
54
+ { type: 'tip', title: 'A percentage needs its denominator', html: 'A pairwise agreement of 100% over two shared votes is a different piece of evidence from 100% over eighty. Read the comparable count beside every rate, and treat a low sample as a prompt for more data rather than as a stable pattern.' },
55
+ { type: 'title', text: 'Interpret the Ribbon Without Overclaiming', level: 2 },
56
+ { type: 'paragraph', html: 'The ribbon is a reading aid, not a political verdict. Blue marks represent the group majority of yes choices, rust marks represent a majority of no choices, outlined marks show abstention, and quiet marks show absence or unknown data. The score beside a row summarizes binary choices only, while the participation counts show what was left out of that denominator.' },
57
+ { type: 'paragraph', html: 'High cohesion can arise from shared preferences, party discipline, coalition bargaining, a narrowly framed motion, or a small group. High agreement between two groups can reflect an agenda with little disagreement. A low score can reflect a genuinely divided vote or a changing membership record. The analyzer describes the matrix and cannot choose among those explanations.' },
58
+ { type: 'title', text: 'Method Limits and Data Warnings', level: 2 },
59
+ { type: 'paragraph', html: 'The tool follows the conventional binary Rice calculation and a transparent pairwise agreement rule. It does not infer ideology, intent, misconduct, or legal obligations. Check the official roll call definitions before treating an abstention, absence, or unknown value as comparable across parliaments.' },
60
+ { type: 'tip', title: 'Use warnings as part of the result', html: 'Skipped rows, duplicate identifiers, unknown choices, tied group majorities, and low comparable samples are not cosmetic notices. Resolve them or document them before comparing periods, groups, or members.' },
61
+ ],
62
+ faq: [
63
+ { question: 'What does the Rice Index measure?', answer: 'For one group and one vote, it is the absolute difference between yes and no votes divided by their sum. It ranges from zero for an even split to one for a unanimous binary vote.' },
64
+ { question: 'Are abstentions and absences included?', answer: 'No. The binary Rice denominator uses only yes and no choices. Abstentions, absences, and unknown values remain visible and are excluded from the binary score.' },
65
+ { question: 'Can agreement prove that two groups share an ideology?', answer: 'No. Agreement only describes recorded binary choices on comparable votes. It does not identify ideology, intent, collusion, or causation.' },
66
+ { question: 'Why does the analyzer show comparable vote counts?', answer: 'A percentage without its denominator can hide missing records. The comparable count shows how many shared votes contributed to each agreement rate.' },
67
+ ],
68
+ bibliography,
69
+ howTo: [
70
+ { name: 'Prepare one row per choice', text: 'Create CSV rows with member, group, vote, and choice columns.' },
71
+ { name: 'Load the matrix', text: 'Paste the CSV or load the example, then check the group and vote counts.' },
72
+ { name: 'Read group cohesion', text: 'Use the ribbon and Rice Index summary to inspect binary cohesion.' },
73
+ { name: 'Compare choices', text: 'Switch between group and member agreement and read the comparable count.' },
74
+ { name: 'Review warnings', text: 'Investigate skipped rows, duplicates, unknown choices, ties, and low samples.' },
75
+ ],
76
+ schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[],
77
+ };
78
+
79
+ export const englishContent: ParliamentaryVotingLocaleContent = content;
@@ -0,0 +1,49 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import { bibliography } from '../bibliography';
3
+ import type { ParliamentaryVotingLocaleContent } from '../entry';
4
+ import type { ParliamentaryVotingUI } from '../ui';
5
+
6
+ const ui: ParliamentaryVotingUI = {
7
+ dataHeading: 'Da visibilidad a una votación nominal', dataHelp: 'Pega una fila por miembro y votación. Usa cuatro columnas llamadas miembro, grupo, votación y opción.', csvLabel: 'CSV de votación nominal', csvHint: 'Columnas obligatorias: miembro, grupo, votación y opción. Opciones aceptadas: sí, no, abstención, ausencia y desconocido.', loadExample: 'Cargar ejemplo', analyzeAction: 'Analizar votos', clearAction: 'Borrar datos', compareLabel: 'Vista de acuerdo', groupsOption: 'Grupos', membersOption: 'Miembros', resultHeading: 'El registro de votación de un vistazo', emptyResult: 'Pega una votación o carga el ejemplo para revelar la cinta de votos.', rowsLabel: 'registros', groupsLabel: 'grupos', votesLabel: 'votaciones', warningsHeading: 'Comprobaciones antes de interpretar', methodHeading: 'Método aplicado', methodText: 'El índice de Rice de cada grupo y votación es la diferencia absoluta entre sí y no dividida por sí más no. Las abstenciones, ausencias y opciones desconocidas quedan fuera de ese denominador binario. Las medias de grupo son promedios no ponderados de las votaciones con al menos un sí o un no. El acuerdo compara opciones binarias en la misma votación y siempre muestra el número de votaciones comparables.', limitsHeading: 'Lo que esta herramienta no hace', limitsText: 'No infiere ideología, intención, disciplina, connivencia, mala conducta, posiciones políticas ni causalidad. Tampoco decide si una votación fue válida o si un miembro tenía obligación legal de asistir. Un voto parecido puede tener muchas explicaciones.', edgeHeading: 'Casos límite y avisos de datos', edgeText: 'Una votación unánime obtiene una puntuación de 1, no una prueba de disciplina. Los empates producen cohesión cero y se marcan aparte de las abstenciones. Los votos ausentes reducen la muestra comparable. Mantén visibles en los datos de origen los cambios de grupo, los identificadores duplicados y las opciones desconocidas.', cohesionHeading: 'Cinta de cohesión', cohesionHelp: 'Cada fila es un grupo. Una marca brillante es un sí, una oscura un no, una discontinua un empate, una hueca una abstención y una tenue una ausencia o un dato desconocido.', agreementHeading: 'Acuerdo por pares', agreementHelp: 'El acuerdo usa solo opciones sí y no comparables compartidas por ambas entidades. En la vista de grupos, cada grupo aporta su opción mayoritaria no empatada. Las abstenciones y no participaciones quedan fuera del numerador y del denominador.', groupLabel: 'Grupo', voteLabel: 'Votación', riceLabel: 'Índice de Rice', comparableLabel: 'Votaciones comparables', participationLabel: 'Opciones registradas', abstentionsLabel: 'abstenciones', absencesLabel: 'ausencias', unknownLabel: 'desconocidas', noComparableData: 'No hay opciones sí o no comparables', noDataWarning: 'No se encontraron registros utilizables. Añade valores de miembro, grupo, votación y opción.', malformedRowWarning: 'Se omitieron algunas filas porque faltaba un campo obligatorio.', duplicateWarning: 'Los pares duplicados de miembro y votación se conservaron una sola vez.', unknownChoiceWarning: 'Algunas opciones se conservaron como desconocidas porque no se reconocieron como sí, no, abstención o ausencia.', lowSampleWarning: 'Algunos acuerdos por pares tienen menos de dos votaciones comparables y no deben tratarse como patrones estables.', tieWarning: 'El empate de un grupo o entidad se excluyó de la comparación por grupos para esa votación.', importedMessage: 'Ejemplo cargado. Puedes editar las filas antes de analizar.', invalidCsvMessage: 'Los datos no se pudieron leer como un CSV basado en filas.', copyAction: 'Copiar resumen', copiedMessage: 'Resumen copiado al portapapeles.', copyFailure: 'No se pudo copiar. Selecciona el texto del resumen manualmente.',
8
+ };
9
+
10
+ const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Analizador de votaciones parlamentarias', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Analiza la cohesión y el acuerdo por pares en votaciones nominales parlamentarias a partir de una matriz transparente.', url: 'https://gamebob.dev/es/analizador-votaciones-parlamentarias', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
11
+ const faqPage: FAQPage = { '@type': 'FAQPage', mainEntity: [
12
+ { '@type': 'Question', name: '¿Qué mide el índice de Rice?', acceptedAnswer: { '@type': 'Answer', text: 'Para un grupo y una votación, es la diferencia absoluta entre votos sí y no dividida por su suma. Va de cero cuando hay empate a uno cuando la votación binaria es unánime.' } },
13
+ { '@type': 'Question', name: '¿Se incluyen abstenciones y ausencias?', acceptedAnswer: { '@type': 'Answer', text: 'No. El denominador binario del índice de Rice solo usa opciones sí y no. Las abstenciones, ausencias y opciones desconocidas siguen visibles como señales de participación, pero quedan fuera de la puntuación binaria.' } },
14
+ { '@type': 'Question', name: '¿El acuerdo demuestra que dos grupos comparten ideología?', acceptedAnswer: { '@type': 'Answer', text: 'No. El acuerdo solo describe las opciones binarias registradas en las votaciones que pueden compararse. No identifica ideología, intención, connivencia ni causalidad.' } },
15
+ { '@type': 'Question', name: '¿Por qué se muestran recuentos comparables?', acceptedAnswer: { '@type': 'Answer', text: 'Un porcentaje sin denominador puede ocultar registros ausentes. El recuento comparable muestra cuántas votaciones compartidas contribuyeron realmente a cada tasa de acuerdo.' } },
16
+ ] };
17
+ const howTo: HowTo = { '@type': 'HowTo', name: 'Analizar votaciones nominales parlamentarias', step: [
18
+ { '@type': 'HowToStep', name: 'Prepara una fila por opción', text: 'Crea filas CSV con las columnas miembro, grupo, votación y opción. Usa sí, no, abstención, ausencia o desconocido.' },
19
+ { '@type': 'HowToStep', name: 'Carga la matriz', text: 'Pega el CSV o carga el ejemplo y comprueba que los recuentos de grupos y votaciones coinciden con tu fuente.' },
20
+ { '@type': 'HowToStep', name: 'Lee la cohesión', text: 'Usa la cinta y el resumen del índice de Rice para examinar la cohesión binaria de cada grupo y votación.' },
21
+ { '@type': 'HowToStep', name: 'Compara opciones', text: 'Cambia entre acuerdo de grupos y de miembros, y lee cada tasa junto a su número de votaciones comparables.' },
22
+ { '@type': 'HowToStep', name: 'Revisa los avisos', text: 'Investiga filas omitidas, duplicados, opciones desconocidas, empates y muestras pequeñas antes de sacar conclusiones.' },
23
+ ] };
24
+ const faqItems = (faqPage.mainEntity ?? []) as unknown as Array<{ name?: string; acceptedAnswer?: { text?: string } }>;
25
+ const howToSteps = (howTo.step ?? []) as unknown as Array<{ name?: string; text?: string }>;
26
+
27
+ export const content: ParliamentaryVotingLocaleContent = {
28
+ slug: 'analizador-votaciones-parlamentarias', title: 'Analizador de votaciones parlamentarias', description: 'Mide la cohesión de los grupos y el acuerdo por pares en una matriz de votaciones nominales, manteniendo visibles las abstenciones, ausencias, denominadores y advertencias.', ui,
29
+ seo: [
30
+ { type: 'title', text: 'Examina la cohesión de una votación nominal parlamentaria', level: 2 },
31
+ { type: 'paragraph', html: 'Una votación nominal registra quién votó, pero una lista larga de síes y noes no muestra de inmediato si un grupo actuó unido. Este analizador convierte una matriz por filas en una cinta visual, resúmenes por grupo y resultados de acuerdo por pares. Conserva las diferencias del registro para que un porcentaje atractivo no borre las ausencias ni las opciones no binarias.' },
32
+ { type: 'paragraph', html: 'La entrada prevista es una tabla con una fila por miembro y votación. Cada fila nombra al miembro, su grupo parlamentario, el identificador de la votación y la opción registrada. El analizador acepta etiquetas habituales como a favor, en contra, abstención y no votó; una opción no reconocida se conserva como desconocida en lugar de recodificarse en silencio.' },
33
+ { type: 'title', text: 'Cómo se calcula el índice de Rice', level: 2 },
34
+ { type: 'paragraph', html: 'Para un grupo en una votación, el índice de Rice es |sí menos no| dividido por sí más no. Una votación binaria unánime vale 1, un reparto exacto vale 0 y un grupo sin síes ni noes no tiene valor de Rice. El resumen del grupo es la media no ponderada de los valores disponibles, por lo que los datos binarios ausentes no se convierten en ceros inventados.' },
35
+ { type: 'table', headers: ['Señal', 'Cálculo', 'Interpretación'], rows: [['Índice de Rice', '|sí menos no| / (sí más no)', 'Cohesión binaria de un grupo y una votación'], ['Rice medio', 'Media de los valores disponibles', 'Cohesión habitual en votaciones comparables'], ['Acuerdo', 'Opciones binarias coincidentes / votaciones compartidas comparables', 'Coincidencia observada entre dos entidades']] },
36
+ { type: 'title', text: 'Prepara una matriz que se pueda comprobar', level: 2 },
37
+ { type: 'paragraph', html: 'Usa un identificador estable para cada votación y conserva las categorías originales hasta terminar la importación. No conviertas una ausencia en un no ni una abstención en una celda vacía. Esas decisiones cambian los recuentos de participación y el conjunto de observaciones comparables.' },
38
+ { type: 'list', items: ['Confirma que cada fila pertenece a la cámara y al periodo que quieres estudiar.', 'Mantén consistentes los nombres de miembros y grupos, incluidos acentos y signos de puntuación.', 'Asigna identificadores distintos a mociones repetidas cuando sean votaciones nominales separadas.', 'Comprueba los pares duplicados de miembro y votación con el registro original antes de aceptar la primera fila.', 'Guarda el archivo de origen y el texto importado exacto junto a cualquier interpretación publicada.'] },
39
+ { type: 'tip', title: 'Todo porcentaje necesita denominador', html: 'Un acuerdo por pares del 100 % en dos votaciones compartidas es una evidencia distinta del 100 % en ochenta. Lee el recuento comparable junto a cada tasa y trata una muestra pequeña como una invitación a reunir más datos, no como un patrón estable.' },
40
+ { type: 'title', text: 'Interpreta la cinta sin exagerar lo que demuestra', level: 2 },
41
+ { type: 'paragraph', html: 'La cinta ayuda a leer los datos, pero no emite un veredicto político. Las marcas azules representan la mayoría de síes del grupo, las rojizas una mayoría de noes, las marcas delineadas una abstención y las tenues una ausencia o un dato desconocido. La puntuación junto a la fila resume solo las opciones binarias; los recuentos de participación muestran qué quedó fuera del denominador.' },
42
+ { type: 'paragraph', html: 'Una cohesión alta puede surgir de preferencias compartidas, disciplina de partido, negociación de coalición, una moción muy concreta o un grupo pequeño. Un acuerdo alto entre dos grupos puede reflejar una agenda con poco desacuerdo. Una puntuación baja puede reflejar una división real o cambios en el registro de miembros. El analizador describe la matriz y no puede elegir entre esas explicaciones.' },
43
+ { type: 'title', text: 'Límites del método y avisos de datos', level: 2 },
44
+ { type: 'paragraph', html: 'La herramienta sigue el cálculo binario convencional de Rice y una regla transparente de acuerdo por pares. No infiere ideología, intención, mala conducta ni obligaciones legales. Comprueba las definiciones oficiales de la votación antes de tratar una abstención, ausencia u opción desconocida como comparable entre parlamentos.' },
45
+ { type: 'tip', title: 'Los avisos forman parte del resultado', html: 'Las filas omitidas, identificadores duplicados, opciones desconocidas, mayorías empatadas y muestras comparables pequeñas no son avisos decorativos. Resuélvelos o documéntalos antes de comparar periodos, grupos o miembros.' },
46
+ ],
47
+ faq: faqItems.map((item) => ({ question: String(item.name), answer: String(item.acceptedAnswer?.text) })), bibliography,
48
+ howTo: howToSteps.map((step) => ({ name: String(step.name), text: String(step.text) })), schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[],
49
+ };