@jjlmoya/utils-civic 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/referendum-threshold-calculator/bibliography.astro +6 -0
- package/src/tool/referendum-threshold-calculator/bibliography.ts +6 -0
- package/src/tool/referendum-threshold-calculator/component.astro +117 -0
- package/src/tool/referendum-threshold-calculator/controller.ts +141 -0
- package/src/tool/referendum-threshold-calculator/dom-views.ts +71 -0
- package/src/tool/referendum-threshold-calculator/entry.ts +27 -0
- package/src/tool/referendum-threshold-calculator/evaluator.ts +19 -0
- package/src/tool/referendum-threshold-calculator/i18n/de.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/en.ts +79 -0
- package/src/tool/referendum-threshold-calculator/i18n/es.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/fr.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/id.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/it.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ja.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ko.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/nl.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/pl.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/pt.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/ru.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/sv.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/tr.ts +56 -0
- package/src/tool/referendum-threshold-calculator/i18n/zh.ts +56 -0
- package/src/tool/referendum-threshold-calculator/index.ts +11 -0
- package/src/tool/referendum-threshold-calculator/logic.test.ts +69 -0
- package/src/tool/referendum-threshold-calculator/logic.ts +120 -0
- package/src/tool/referendum-threshold-calculator/referendum-threshold-calculator.css +640 -0
- package/src/tool/referendum-threshold-calculator/seo.astro +14 -0
- package/src/tool/referendum-threshold-calculator/sharing.test.ts +23 -0
- package/src/tool/referendum-threshold-calculator/sharing.ts +42 -0
- package/src/tool/referendum-threshold-calculator/storage.ts +39 -0
- package/src/tool/referendum-threshold-calculator/ui.ts +126 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { coalitionMajorityCalculator } from '../tool/coalition-majority-calculator/entry';
|
|
2
2
|
import { electionSeatApportionmentCalculator } from '../tool/election-seat-apportionment-calculator/entry';
|
|
3
|
+
import { referendumThresholdCalculator } from '../tool/referendum-threshold-calculator/entry';
|
|
3
4
|
import { voteSeatDisproportionalityAnalyzer } from '../tool/vote-seat-disproportionality-analyzer/entry';
|
|
4
5
|
import type { CategoryLocaleContent, KnownLocale } from '../types';
|
|
5
6
|
|
|
6
7
|
export const civicCategory = {
|
|
7
8
|
icon: 'mdi:bank-outline',
|
|
8
|
-
tools: [coalitionMajorityCalculator, electionSeatApportionmentCalculator, voteSeatDisproportionalityAnalyzer],
|
|
9
|
+
tools: [coalitionMajorityCalculator, electionSeatApportionmentCalculator, referendumThresholdCalculator, voteSeatDisproportionalityAnalyzer],
|
|
9
10
|
i18n: {
|
|
10
11
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
11
12
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -6,10 +6,12 @@ export { civicCategory } from './category';
|
|
|
6
6
|
|
|
7
7
|
import { coalitionMajorityCalculator } from './tool/coalition-majority-calculator/entry';
|
|
8
8
|
import { electionSeatApportionmentCalculator } from './tool/election-seat-apportionment-calculator/entry';
|
|
9
|
+
import { referendumThresholdCalculator } from './tool/referendum-threshold-calculator/entry';
|
|
9
10
|
import { voteSeatDisproportionalityAnalyzer } from './tool/vote-seat-disproportionality-analyzer/entry';
|
|
10
11
|
|
|
11
12
|
export const ALL_ENTRIES = [
|
|
12
13
|
coalitionMajorityCalculator,
|
|
13
14
|
electionSeatApportionmentCalculator,
|
|
15
|
+
referendumThresholdCalculator,
|
|
14
16
|
voteSeatDisproportionalityAnalyzer,
|
|
15
17
|
];
|
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 { referendumThresholdCalculator, REFERENDUM_THRESHOLD_CALCULATOR_TOOL } from './tool/referendum-threshold-calculator';
|
|
5
6
|
export { voteSeatDisproportionalityAnalyzer, VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL } from './tool/vote-seat-disproportionality-analyzer';
|
|
6
7
|
|
|
7
8
|
export type {
|
|
@@ -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(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(4);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('civicCategory should be defined', () => {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'International IDEA, Direct Democracy Handbook, chapter on referendum rules', url: 'https://www.idea.int/sites/default/files/publications/direct-democracy-the-international-idea-handbook.pdf' },
|
|
5
|
+
{ name: 'Commission de Venise, Code de bonne conduite en matière référendaire révisé', url: 'https://www.venice.coe.int/webforms/documents/?pdf=CDL-AD%282022%29015-f' },
|
|
6
|
+
];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ReferendumThresholdUI } from './ui';
|
|
3
|
+
import './referendum-threshold-calculator.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui: ReferendumThresholdUI;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui } = Astro.props as Props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="referendum-tool" data-referendum-threshold-tool>
|
|
13
|
+
<div class="rule-workbench">
|
|
14
|
+
<form class="rule-form" data-referendum-form>
|
|
15
|
+
<fieldset class="count-block">
|
|
16
|
+
<legend>{ui.countsHeading}</legend>
|
|
17
|
+
<label class="wide-field">{ui.eligibleLabel}<input name="eligibleVoters" type="number" min="0" step="1" value="1000" required /><small>{ui.eligibleHelp}</small></label>
|
|
18
|
+
<div class="count-grid">
|
|
19
|
+
<label>{ui.yesLabel}<input name="yes" type="number" min="0" step="1" value="520" required /></label>
|
|
20
|
+
<label>{ui.noLabel}<input name="no" type="number" min="0" step="1" value="300" required /></label>
|
|
21
|
+
<label>{ui.blankLabel}<input name="blank" type="number" min="0" step="1" value="20" required /></label>
|
|
22
|
+
<label>{ui.invalidLabel}<input name="invalid" type="number" min="0" step="1" value="10" required /></label>
|
|
23
|
+
</div>
|
|
24
|
+
</fieldset>
|
|
25
|
+
|
|
26
|
+
<fieldset class="quorum-block participation-block">
|
|
27
|
+
<legend>{ui.participationHeading}</legend>
|
|
28
|
+
<p>{ui.participationHelp}</p>
|
|
29
|
+
<div class="mode-switch" role="group" aria-label={ui.thresholdModeLabel}>
|
|
30
|
+
<button type="button" class="active" data-mode-prefix="participation" data-mode="percentage" aria-pressed="true">{ui.percentageOption}</button>
|
|
31
|
+
<button type="button" data-mode-prefix="participation" data-mode="absolute" aria-pressed="false">{ui.absoluteOption}</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="comparison-row" role="group" aria-label={ui.comparisonLabel}>
|
|
34
|
+
<span>{ui.comparisonLabel}</span>
|
|
35
|
+
<input name="participationComparison" type="hidden" value="at-least" />
|
|
36
|
+
<button type="button" class="active" data-comparison-prefix="participation" data-comparison="at-least" aria-pressed="true">{ui.atLeastOption}</button>
|
|
37
|
+
<button type="button" data-comparison-prefix="participation" data-comparison="more-than" aria-pressed="false">{ui.moreThanOption}</button>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="quorum-fields">
|
|
40
|
+
<input name="participationMode" type="hidden" value="percentage" />
|
|
41
|
+
<label>{ui.thresholdValueLabel}<input name="participationThreshold" type="number" min="0" max="100" step="0.1" value="50" required /></label>
|
|
42
|
+
<div class="custom-select" data-select>
|
|
43
|
+
<span class="field-label">{ui.denominatorLabel}</span>
|
|
44
|
+
<input name="participationDenominator" type="hidden" value="all-ballots" />
|
|
45
|
+
<button type="button" data-select-trigger aria-expanded="false">{ui.allBallotsOption}</button>
|
|
46
|
+
<div class="select-menu" data-select-menu hidden role="listbox">
|
|
47
|
+
<button type="button" data-option="all-ballots" role="option">{ui.allBallotsOption}</button>
|
|
48
|
+
<button type="button" data-option="valid-votes" role="option">{ui.validVotesOption}</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</fieldset>
|
|
53
|
+
|
|
54
|
+
<fieldset class="quorum-block approval-block">
|
|
55
|
+
<legend>{ui.approvalHeading}</legend>
|
|
56
|
+
<p>{ui.approvalHelp}</p>
|
|
57
|
+
<div class="mode-switch" role="group" aria-label={ui.thresholdModeLabel}>
|
|
58
|
+
<button type="button" class="active" data-mode-prefix="approval" data-mode="percentage" aria-pressed="true">{ui.percentageOption}</button>
|
|
59
|
+
<button type="button" data-mode-prefix="approval" data-mode="absolute" aria-pressed="false">{ui.absoluteOption}</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="comparison-row" role="group" aria-label={ui.comparisonLabel}>
|
|
62
|
+
<span>{ui.comparisonLabel}</span>
|
|
63
|
+
<input name="approvalComparison" type="hidden" value="at-least" />
|
|
64
|
+
<button type="button" class="active" data-comparison-prefix="approval" data-comparison="at-least" aria-pressed="true">{ui.atLeastOption}</button>
|
|
65
|
+
<button type="button" data-comparison-prefix="approval" data-comparison="more-than" aria-pressed="false">{ui.moreThanOption}</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="preset-row" role="group" aria-label={ui.presetsLabel}>
|
|
68
|
+
<span>{ui.presetsLabel}</span>
|
|
69
|
+
<button type="button" data-preset="simple-majority">{ui.simpleMajorityPreset}</button>
|
|
70
|
+
<button type="button" data-preset="electorate-majority">{ui.electorateMajorityPreset}</button>
|
|
71
|
+
<button type="button" data-preset="qualified-55">{ui.qualified55Preset}</button>
|
|
72
|
+
<button type="button" data-preset="qualified-60">{ui.qualified60Preset}</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="quorum-fields">
|
|
75
|
+
<input name="approvalMode" type="hidden" value="percentage" />
|
|
76
|
+
<label>{ui.thresholdValueLabel}<input name="approvalThreshold" type="number" min="0" max="100" step="0.1" value="50" required /></label>
|
|
77
|
+
<div class="custom-select" data-select>
|
|
78
|
+
<span class="field-label">{ui.denominatorLabel}</span>
|
|
79
|
+
<input name="approvalDenominator" type="hidden" value="valid-votes" />
|
|
80
|
+
<button type="button" data-select-trigger aria-expanded="false">{ui.validVotesOption}</button>
|
|
81
|
+
<div class="select-menu" data-select-menu hidden role="listbox">
|
|
82
|
+
<button type="button" data-option="all-ballots" role="option">{ui.allBallotsOption}</button>
|
|
83
|
+
<button type="button" data-option="valid-votes" role="option">{ui.validVotesOption}</button>
|
|
84
|
+
<button type="button" data-option="eligible-voters" role="option">{ui.eligibleVotersOption}</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</fieldset>
|
|
89
|
+
|
|
90
|
+
<div class="form-actions"><button class="secondary-action" type="button" data-reset>{ui.loadExample}</button><button class="share-action" type="button" data-share>{ui.shareAction}</button></div>
|
|
91
|
+
<p class="tool-status" data-status role="status"></p>
|
|
92
|
+
</form>
|
|
93
|
+
|
|
94
|
+
<section class="decision-panel" aria-live="polite" aria-labelledby="referendum-result-heading">
|
|
95
|
+
<div class="panel-heading"><span class="panel-index">02</span><h2 id="referendum-result-heading">{ui.resultHeading}</h2></div>
|
|
96
|
+
<div class="decision-scene" data-scene></div>
|
|
97
|
+
<div class="empty-result" data-empty-result>{ui.emptyResult}</div>
|
|
98
|
+
<div class="result-content" data-result hidden></div>
|
|
99
|
+
</section>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<section class="guardrails" aria-label="Method and limits">
|
|
103
|
+
<article><span class="guardrail-mark">01</span><h2>{ui.methodHeading}</h2><p>{ui.methodText}</p></article>
|
|
104
|
+
<article><span class="guardrail-mark">02</span><h2>{ui.limitsHeading}</h2><p>{ui.limitsText}</p></article>
|
|
105
|
+
<article><span class="guardrail-mark">03</span><h2>{ui.warningsHeading}</h2><p>{ui.warningsText}</p></article>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
<script is:inline type="application/json" data-referendum-threshold-config set:html={JSON.stringify({ ui })}></script>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<script>
|
|
112
|
+
import { mountReferendumThresholdTool } from './controller';
|
|
113
|
+
|
|
114
|
+
const root = document.querySelector<HTMLElement>('[data-referendum-threshold-tool]');
|
|
115
|
+
const config = root?.querySelector<HTMLScriptElement>('[data-referendum-threshold-config]');
|
|
116
|
+
if (root && config) mountReferendumThresholdTool(root, JSON.parse(config.textContent || '{}').ui);
|
|
117
|
+
</script>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { evaluateReferendum, getExampleInput, type DenominatorMode, type ReferendumInput, type ThresholdComparison, type ThresholdMode, type ThresholdRule } from './logic';
|
|
2
|
+
import { renderDecision, renderDecisionScene, formatInputValue } from './dom-views';
|
|
3
|
+
import { loadSavedInput, saveInput } from './storage';
|
|
4
|
+
import { buildShareUrl, readShareUrl } from './sharing';
|
|
5
|
+
import type { ReferendumThresholdUI } from './ui';
|
|
6
|
+
|
|
7
|
+
function getInput(root: HTMLElement, name: string): HTMLInputElement {
|
|
8
|
+
return root.querySelector<HTMLInputElement>(`[name="${name}"]`)!;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function numericValue(root: HTMLElement, name: string): number {
|
|
12
|
+
return Number(getInput(root, name).value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function selectedValue(root: HTMLElement, name: string): string {
|
|
16
|
+
return getInput(root, name).value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readRule(root: HTMLElement, prefix: string): ThresholdRule {
|
|
20
|
+
return { mode: selectedValue(root, `${prefix}Mode`) as ThresholdMode, value: numericValue(root, `${prefix}Threshold`), denominator: selectedValue(root, `${prefix}Denominator`) as DenominatorMode, comparison: selectedValue(root, `${prefix}Comparison`) as ThresholdComparison };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readInput(root: HTMLElement): ReferendumInput {
|
|
24
|
+
return { counts: { eligibleVoters: numericValue(root, 'eligibleVoters'), yes: numericValue(root, 'yes'), no: numericValue(root, 'no'), blank: numericValue(root, 'blank'), invalid: numericValue(root, 'invalid') }, rules: { participation: readRule(root, 'participation'), approval: readRule(root, 'approval') } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function updateSelectLabel(root: HTMLElement, name: string): void {
|
|
28
|
+
const field = getInput(root, name);
|
|
29
|
+
const select = field.closest<HTMLElement>('[data-select]');
|
|
30
|
+
const active = select?.querySelector<HTMLElement>(`[data-option="${field.value}"]`);
|
|
31
|
+
const trigger = select?.querySelector<HTMLButtonElement>('[data-select-trigger]');
|
|
32
|
+
if (trigger && active) trigger.textContent = active.textContent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updateModeButtons(root: HTMLElement, prefix: string, mode: ThresholdMode): void {
|
|
36
|
+
const threshold = getInput(root, `${prefix}Threshold`);
|
|
37
|
+
threshold.max = mode === 'percentage' ? '100' : '';
|
|
38
|
+
root.querySelectorAll<HTMLButtonElement>(`[data-mode-prefix="${prefix}"]`).forEach((button) => { const active = button.dataset.mode === mode; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateComparisonButtons(root: HTMLElement, prefix: string, comparison: ThresholdComparison): void {
|
|
42
|
+
root.querySelectorAll<HTMLButtonElement>(`[data-comparison-prefix="${prefix}"]`).forEach((button) => { const active = button.dataset.comparison === comparison; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function updateRule(root: HTMLElement, prefix: string, rule: ThresholdRule): void {
|
|
46
|
+
getInput(root, `${prefix}Mode`).value = rule.mode;
|
|
47
|
+
getInput(root, `${prefix}Threshold`).value = formatInputValue(rule.value);
|
|
48
|
+
getInput(root, `${prefix}Denominator`).value = rule.denominator;
|
|
49
|
+
getInput(root, `${prefix}Comparison`).value = rule.comparison ?? 'at-least';
|
|
50
|
+
updateSelectLabel(root, `${prefix}Denominator`);
|
|
51
|
+
updateModeButtons(root, prefix, rule.mode);
|
|
52
|
+
updateComparisonButtons(root, prefix, rule.comparison ?? 'at-least');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emitChange(field: HTMLInputElement): void {
|
|
56
|
+
field.dispatchEvent(new Event('change', { bubbles: true }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeInput(root: HTMLElement, input: ReferendumInput): void {
|
|
60
|
+
Object.entries(input.counts).forEach(([name, value]) => { getInput(root, name).value = formatInputValue(value); });
|
|
61
|
+
['participation', 'approval'].forEach((prefix) => { updateRule(root, prefix, input.rules[prefix as 'participation' | 'approval'] as ThresholdRule); });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wireSelect(root: HTMLElement, select: HTMLElement): void {
|
|
65
|
+
const trigger = select.querySelector<HTMLButtonElement>('[data-select-trigger]');
|
|
66
|
+
const menu = select.querySelector<HTMLElement>('[data-select-menu]');
|
|
67
|
+
const field = select.querySelector<HTMLInputElement>('input[type="hidden"]');
|
|
68
|
+
if (!trigger || !menu || !field) return;
|
|
69
|
+
trigger.addEventListener('click', () => { const open = menu.hidden; root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((item) => { item.hidden = true; }); menu.hidden = !open; trigger.setAttribute('aria-expanded', String(open)); });
|
|
70
|
+
menu.querySelectorAll<HTMLButtonElement>('[data-option]').forEach((option) => option.addEventListener('click', () => { field.value = option.dataset.option ?? ''; updateSelectLabel(root, field.name); menu.hidden = true; trigger.setAttribute('aria-expanded', 'false'); emitChange(field); }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function wireModeButtons(root: HTMLElement): void {
|
|
74
|
+
root.querySelectorAll<HTMLButtonElement>('[data-mode-prefix]').forEach((button) => button.addEventListener('click', () => { const prefix = button.dataset.modePrefix!; const mode = button.dataset.mode as ThresholdMode; const field = getInput(root, `${prefix}Mode`); field.value = mode; updateModeButtons(root, prefix, mode); emitChange(field); }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function wireComparisonButtons(root: HTMLElement): void {
|
|
78
|
+
root.querySelectorAll<HTMLButtonElement>('[data-comparison-prefix]').forEach((button) => button.addEventListener('click', () => { const prefix = button.dataset.comparisonPrefix!; const comparison = button.dataset.comparison as ThresholdComparison; const field = getInput(root, `${prefix}Comparison`); field.value = comparison; updateComparisonButtons(root, prefix, comparison); emitChange(field); }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wirePresets(root: HTMLElement): void {
|
|
82
|
+
const presets: Record<string, ThresholdRule> = {
|
|
83
|
+
'simple-majority': { mode: 'percentage', value: 50, denominator: 'valid-votes', comparison: 'more-than' },
|
|
84
|
+
'electorate-majority': { mode: 'percentage', value: 50, denominator: 'eligible-voters', comparison: 'more-than' },
|
|
85
|
+
'qualified-55': { mode: 'percentage', value: 55, denominator: 'valid-votes', comparison: 'at-least' },
|
|
86
|
+
'qualified-60': { mode: 'percentage', value: 60, denominator: 'valid-votes', comparison: 'at-least' },
|
|
87
|
+
};
|
|
88
|
+
root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => { const preset = presets[button.dataset.preset ?? '']; if (preset) { updateRule(root, 'approval', preset); emitChange(getInput(root, 'approvalThreshold')); } }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function wireOutsideSelectClose(root: HTMLElement): void {
|
|
92
|
+
root.addEventListener('click', (event) => {
|
|
93
|
+
if ((event.target as Element).closest('[data-select]')) return;
|
|
94
|
+
root.querySelectorAll<HTMLElement>('[data-select-menu]').forEach((menu) => { menu.hidden = true; });
|
|
95
|
+
root.querySelectorAll<HTMLButtonElement>('[data-select-trigger]').forEach((trigger) => { trigger.setAttribute('aria-expanded', 'false'); });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function wireShare(root: HTMLElement, ui: ReferendumThresholdUI): void {
|
|
100
|
+
root.querySelector<HTMLButtonElement>('[data-share]')!.addEventListener('click', async () => {
|
|
101
|
+
const url = buildShareUrl(window.location.href, readInput(root));
|
|
102
|
+
window.history.replaceState({}, '', url);
|
|
103
|
+
try { if (!navigator.clipboard) throw new Error('clipboard-unavailable'); await navigator.clipboard.writeText(url); root.querySelector<HTMLElement>('[data-status]')!.textContent = ui.shareSuccess; } catch { root.querySelector<HTMLElement>('[data-status]')!.textContent = ui.shareFailure; }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function showEvaluation(root: HTMLElement, input: ReferendumInput, ui: ReferendumThresholdUI): void {
|
|
108
|
+
const evaluation = evaluateReferendum(input);
|
|
109
|
+
root.querySelector<HTMLElement>('[data-empty-result]')!.hidden = true;
|
|
110
|
+
root.querySelector<HTMLElement>('[data-result]')!.hidden = false;
|
|
111
|
+
renderDecisionScene(root.querySelector<HTMLElement>('[data-scene]')!, evaluation, ui);
|
|
112
|
+
renderDecision(root.querySelector<HTMLElement>('[data-result]')!, evaluation, ui);
|
|
113
|
+
root.querySelector<HTMLElement>('[data-status]')!.textContent = evaluation.errors.includes('ballots-exceed-eligible') ? ui.ballotsExceedEligible : '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function refreshEvaluation(root: HTMLElement, ui: ReferendumThresholdUI): void {
|
|
117
|
+
const input = readInput(root);
|
|
118
|
+
saveInput(input);
|
|
119
|
+
showEvaluation(root, input, ui);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function wireForm(root: HTMLElement, ui: ReferendumThresholdUI): void {
|
|
123
|
+
const form = root.querySelector<HTMLFormElement>('[data-referendum-form]')!;
|
|
124
|
+
form.addEventListener('input', () => refreshEvaluation(root, ui));
|
|
125
|
+
form.addEventListener('change', () => refreshEvaluation(root, ui));
|
|
126
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); refreshEvaluation(root, ui); });
|
|
127
|
+
root.querySelector<HTMLButtonElement>('[data-reset]')!.addEventListener('click', () => { writeInput(root, getExampleInput()); refreshEvaluation(root, ui); });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function mountReferendumThresholdTool(root: HTMLElement, ui: ReferendumThresholdUI): void {
|
|
131
|
+
root.querySelectorAll<HTMLElement>('[data-select]').forEach((select) => wireSelect(root, select));
|
|
132
|
+
wireOutsideSelectClose(root);
|
|
133
|
+
wireModeButtons(root);
|
|
134
|
+
wireComparisonButtons(root);
|
|
135
|
+
wirePresets(root);
|
|
136
|
+
wireForm(root, ui);
|
|
137
|
+
wireShare(root, ui);
|
|
138
|
+
const initial = readShareUrl(window.location.href) ?? loadSavedInput() ?? getExampleInput();
|
|
139
|
+
writeInput(root, initial);
|
|
140
|
+
showEvaluation(root, initial, ui);
|
|
141
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReferendumEvaluation, ThresholdRule } from './logic';
|
|
2
|
+
import { getStatusPresentation } from './evaluator';
|
|
3
|
+
import type { ReferendumThresholdUI } from './ui';
|
|
4
|
+
|
|
5
|
+
const numberFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
|
|
6
|
+
const percentFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
|
7
|
+
|
|
8
|
+
function formatNumber(value: number): string {
|
|
9
|
+
return numberFormatter.format(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatPercent(value: number): string {
|
|
13
|
+
return `${percentFormatter.format(value)}%`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function thresholdText(rule: ThresholdRule, ui: ReferendumThresholdUI): string {
|
|
17
|
+
const operator = rule.comparison === 'more-than' ? '> ' : '>= ';
|
|
18
|
+
if (rule.mode === 'absolute') return `${operator}${formatNumber(rule.value)} ${ui.peopleSuffix}`;
|
|
19
|
+
const denominator = denominatorText(rule.denominator, ui);
|
|
20
|
+
return `${operator}${formatNumber(rule.value)}% of ${denominator}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function denominatorText(mode: ThresholdRule['denominator'], ui: ReferendumThresholdUI): string {
|
|
24
|
+
if (mode === 'eligible-voters') return ui.eligibleVotersLabel;
|
|
25
|
+
if (mode === 'valid-votes') return ui.validVotesLabel;
|
|
26
|
+
return ui.allBallotsLabel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markerPosition(rule: ThresholdRule, denominator: number): number {
|
|
30
|
+
if (rule.mode === 'percentage') return rule.value;
|
|
31
|
+
if (denominator <= 0) return 100;
|
|
32
|
+
return (rule.value / denominator) * 100;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function statusTitle(evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): string {
|
|
36
|
+
if (!evaluation.valid) return ui.invalidResult;
|
|
37
|
+
if (evaluation.status === 'passed') return ui.passed;
|
|
38
|
+
if (evaluation.status === 'participation-failed') return ui.participationFailed;
|
|
39
|
+
if (evaluation.status === 'approval-failed') return ui.approvalFailed;
|
|
40
|
+
return ui.bothFailed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gateMarkup(label: string, rule: ThresholdRule, value: ReferendumEvaluation['participation'], ui: ReferendumThresholdUI): string {
|
|
44
|
+
const fill = Math.min(100, Math.max(0, value.rate));
|
|
45
|
+
const marker = markerPosition(rule, value.denominator);
|
|
46
|
+
const state = value.passed ? 'pass' : 'fail';
|
|
47
|
+
return `<div class="quorum-gate ${state}"><div class="gate-top"><span>${label}</span><strong>${formatPercent(value.rate)}</strong></div><div class="gate-track"><span style="width:${fill}%"></span><i style="left:${Math.min(100, Math.max(0, marker))}%"></i></div><div class="gate-bottom"><span>${formatNumber(value.numerator)} / ${formatNumber(value.denominator)}</span><span>required ${thresholdText(rule, ui)}</span></div></div>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderDecisionScene(target: HTMLElement, evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): void {
|
|
51
|
+
const allBallotsRule: ThresholdRule = { mode: evaluation.participation.mode, value: evaluation.participation.threshold, denominator: evaluation.participation.denominatorMode, comparison: evaluation.participation.comparison };
|
|
52
|
+
const validVotesRule: ThresholdRule = { mode: evaluation.approval.mode, value: evaluation.approval.threshold, denominator: evaluation.approval.denominatorMode, comparison: evaluation.approval.comparison };
|
|
53
|
+
target.innerHTML = `<div class="ballot-path" aria-label="${ui.decisionPathLabel}"><div class="ballot-mark"><span class="ballot-check">O</span><span>${formatNumber(evaluation.totals.allBallots)} ${ui.allBallotsLabel}</span></div><div class="path-line"></div><div class="gate-stack">${gateMarkup(ui.participationMetric, allBallotsRule, evaluation.participation, ui)}${gateMarkup(ui.approvalMetric, validVotesRule, evaluation.approval, ui)}</div></div>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ruleCard(title: string, evaluation: ReferendumEvaluation['participation'] | ReferendumEvaluation['approval'], rule: ThresholdRule, ui: ReferendumThresholdUI): string {
|
|
57
|
+
return `<article class="metric-card"><div class="metric-card-head"><span>${title}</span><b class="metric-state ${evaluation.passed ? 'pass' : 'fail'}">${evaluation.passed ? 'PASS' : 'FAIL'}</b></div><strong>${formatPercent(evaluation.rate)}</strong><dl><div><dt>${ui.observedLabel}</dt><dd>${formatNumber(evaluation.numerator)} / ${formatNumber(evaluation.denominator)}</dd></div><div><dt>${ui.thresholdLabel}</dt><dd>${thresholdText(rule, ui)}</dd></div></dl></article>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderDecision(target: HTMLElement, evaluation: ReferendumEvaluation, ui: ReferendumThresholdUI): void {
|
|
61
|
+
const presentation = getStatusPresentation(evaluation);
|
|
62
|
+
const errors = evaluation.errors.length > 0 ? `<div class="inline-message danger" role="alert">${ui.invalidInput}</div>` : '';
|
|
63
|
+
const warnings = evaluation.warnings.map((warning) => warning === 'no-valid-votes' ? ui.noValidVotes : ui.noBallots).map((text) => `<li>${text}</li>`).join('');
|
|
64
|
+
const participationRule: ThresholdRule = { mode: evaluation.participation.mode, value: evaluation.participation.threshold, denominator: evaluation.participation.denominatorMode, comparison: evaluation.participation.comparison };
|
|
65
|
+
const approvalRule: ThresholdRule = { mode: evaluation.approval.mode, value: evaluation.approval.threshold, denominator: evaluation.approval.denominatorMode, comparison: evaluation.approval.comparison };
|
|
66
|
+
target.innerHTML = `<div class="decision-banner ${presentation.tone}"><span class="decision-seal">${evaluation.valid ? 'O' : '!'}</span><div><strong>${statusTitle(evaluation, ui)}</strong><p>${presentation.detail}</p></div></div>${errors}<div class="metric-grid">${ruleCard(ui.participationMetric, evaluation.participation, participationRule, ui)}${ruleCard(ui.approvalMetric, evaluation.approval, approvalRule, ui)}</div><div class="totals-strip"><span>${ui.yesShareLabel} <b>${formatNumber(evaluation.approval.numerator)}</b></span><span>${ui.allBallotsLabel} <b>${formatNumber(evaluation.totals.allBallots)}</b></span><span>${ui.validVotesLabel} <b>${formatNumber(evaluation.totals.validVotes)}</b></span><span>${ui.eligibleVotersLabel} <b>${formatNumber(evaluation.participation.denominator)}</b></span></div>${warnings ? `<div class="warning-list"><strong>${ui.warningsHeading}</strong><ul>${warnings}</ul></div>` : ''}<div class="next-step"><strong>${ui.whatNextLabel}</strong><p>${ui.whatNextText}</p></div>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatInputValue(value: number): string {
|
|
70
|
+
return Number.isFinite(value) ? String(value) : '';
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CivicToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { ReferendumThresholdUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type ReferendumThresholdLocaleContent = ToolLocaleContent<ReferendumThresholdUI>;
|
|
5
|
+
|
|
6
|
+
export const referendumThresholdCalculator: CivicToolEntry<ReferendumThresholdUI> = {
|
|
7
|
+
id: 'referendum-threshold-calculator',
|
|
8
|
+
phase: 'localized',
|
|
9
|
+
icons: { bg: 'mdi:vote-outline', fg: 'mdi:scale-balance' },
|
|
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,19 @@
|
|
|
1
|
+
import type { ReferendumEvaluation, ReferendumStatus } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface StatusPresentation {
|
|
4
|
+
tone: 'positive' | 'warning' | 'danger' | 'neutral';
|
|
5
|
+
title: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const statusDetails: Record<ReferendumStatus, StatusPresentation> = {
|
|
10
|
+
passed: { tone: 'positive', title: 'Both thresholds pass', detail: 'The declared rule accepts the result because participation and approval tests pass.' },
|
|
11
|
+
'participation-failed': { tone: 'warning', title: 'Participation quorum fails', detail: 'The approval test may pass, but the declared participation threshold is not reached.' },
|
|
12
|
+
'approval-failed': { tone: 'warning', title: 'Approval quorum fails', detail: 'Participation reaches its threshold, but the Yes count does not meet the declared approval rule.' },
|
|
13
|
+
'both-failed': { tone: 'danger', title: 'Both thresholds fail', detail: 'Neither declared quorum is reached with the supplied counts.' },
|
|
14
|
+
invalid: { tone: 'danger', title: 'Check the declared counts', detail: 'The inputs contain a structural error, so no decision should be read from the result.' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function getStatusPresentation(evaluation: ReferendumEvaluation): StatusPresentation {
|
|
18
|
+
return statusDetails[evaluation.status];
|
|
19
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import { bibliography } from '../bibliography';
|
|
4
|
+
import type { ReferendumThresholdLocaleContent } from '../entry';
|
|
5
|
+
import type { ReferendumThresholdUI } from '../ui';
|
|
6
|
+
|
|
7
|
+
const ui: ReferendumThresholdUI = {
|
|
8
|
+
countsHeading: 'Gemeldete Stimmenzahlen eingeben', eligibleLabel: 'Wahlberechtigte oder registrierte Wähler', eligibleHelp: 'Verwende den Wählerkreis, den die geprüfte Regel nennt.', yesLabel: 'Ja-Stimmen', noLabel: 'Nein-Stimmen', blankLabel: 'Leere Stimmzettel', invalidLabel: 'Ungültige Stimmzettel', participationHeading: 'Beteiligungsquorum', participationHelp: 'Lege fest, was als Beteiligung zählt und welche Schwelle erreicht werden muss.', approvalHeading: 'Zustimmungsquorum', approvalHelp: 'Wähle den Nenner für die Ja-Schwelle. Jede Regel bleibt im Ergebnis sichtbar.', thresholdModeLabel: 'Schwellentyp', comparisonLabel: 'Vergleich', atLeastOption: 'Mindestens', moreThanOption: 'Mehr als', percentageOption: 'Prozent', absoluteOption: 'Absolute Zahl', thresholdValueLabel: 'Schwellenwert', denominatorLabel: 'Verwendete Zahl', allBallotsOption: 'Alle Stimmzettel', validVotesOption: 'Nur gültige Stimmen', eligibleVotersOption: 'Wahlberechtigte', presetsLabel: 'Vorlagen für Zustimmung', simpleMajorityPreset: 'Einfache Mehrheit', electorateMajorityPreset: 'Mehrheit des Wahlkörpers', qualified55Preset: 'Qualifizierte 55 Prozent', qualified60Preset: 'Qualifizierte 60 Prozent', loadExample: 'Beispiel laden', shareAction: 'Permalink kopieren', shareSuccess: 'Permalink kopiert. Er enthält Zahlen und Regeln.', shareFailure: 'Der Permalink steht in der Adresszeile und kann kopiert werden.', resultHeading: 'Entscheidung nach gemeldeter Regel', emptyResult: 'Gib Zahlen und Schwellen ein, um beide Quorumsprüfungen zu sehen.', passed: 'Beide Schwellen erreicht', participationFailed: 'Beteiligungsquorum nicht erreicht', approvalFailed: 'Zustimmungsquorum nicht erreicht', bothFailed: 'Beide Schwellen nicht erreicht', invalidResult: 'Gemeldete Zahlen prüfen', participationMetric: 'Beteiligungsprüfung', approvalMetric: 'Zustimmungsprüfung', observedLabel: 'Beobachtet', thresholdLabel: 'Erforderlich', decisionPathLabel: 'Entscheidungspfad', methodHeading: 'Angewandte Methode', methodText: 'Die Beteiligung ist die ausgewählte Zahl von Stimmzetteln geteilt durch die Wahlberechtigten. Die Zustimmung ist Ja geteilt durch den gewählten Nenner. Der Vergleich legt fest, ob ein exakter Prozentsatz genügt oder überschritten werden muss. Absolute Schwellen vergleichen direkt die Anzahl.', limitsHeading: 'Was dieses Werkzeug nicht leistet', limitsText: 'Es bestimmt keine zuständige Behörde, keine Frist, keine amtliche Zählung und keine Rechtsberatung. Das Ergebnis ist nur so zuverlässig wie die eingegebenen Annahmen.', warningsHeading: 'Grenzfälle und Datenhinweise', warningsText: 'Prüfe, ob leere und ungültige Stimmzettel zur Beteiligung zählen, ob der Wählerkreis aktuell ist und ob die Zustimmung gegen gültige Stimmen, alle Stimmzettel oder die Wahlberechtigten berechnet wird.', invalidInput: 'Verwende ganze, nichtnegative Zahlen. Die Wahlberechtigten müssen größer als null sein und die Stimmzettel dürfen sie nicht überschreiten.', ballotsExceedEligible: 'Die gemeldeten Stimmzettel überschreiten den Wählerkreis.', noValidVotes: 'Es gibt keine Ja- oder Nein-Stimmen, daher fehlt der Nenner gültiger Stimmen.', noBallots: 'Es wurden keine Stimmzettel gemeldet.', peopleSuffix: 'Personen', yesShareLabel: 'Ja-Anteil', allBallotsLabel: 'alle Stimmzettel', validVotesLabel: 'gültige Stimmen', eligibleVotersLabel: 'Wahlberechtigte', whatNextLabel: 'Vor einer Verwendung des Ergebnisses', whatNextText: 'Übernimm den genauen Wortlaut der geltenden Regel und vergleiche ihn mit den angezeigten Einstellungen.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Referendum Quorum Rechner', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Prüft Beteiligungs- und Zustimmungsquoren mit sichtbaren Zählern und Nennern.', url: 'https://gamebob.dev/de/referendum-quorum-rechner', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } };
|
|
12
|
+
const faqPage: FAQPage = { '@type': 'FAQPage', mainEntity: [
|
|
13
|
+
{ '@type': 'Question', name: 'Was entscheidet dieser Rechner?', acceptedAnswer: { '@type': 'Answer', text: 'Er prüft gemeldete Stimmen gegen die von dir eingegebenen Beteiligungs- und Zustimmungsschwellen. Er legt nicht fest, welche Rechtsregel gilt.' } },
|
|
14
|
+
{ '@type': 'Question', name: 'Was ist der Unterschied zwischen den beiden Quoren?', acceptedAnswer: { '@type': 'Answer', text: 'Das Beteiligungsquorum fragt, ob genügend Stimmen oder Personen erfasst wurden. Das Zustimmungsquorum fragt, ob Ja die geforderte Zahl oder den geforderten Anteil erreicht.' } },
|
|
15
|
+
{ '@type': 'Question', name: 'Warum verändern leere und ungültige Stimmzettel das Ergebnis?', acceptedAnswer: { '@type': 'Answer', text: 'Sie können bei der Beteiligung zählen, aber aus gültigen Stimmen ausgeschlossen werden. Der Rechner macht diese Nenner getrennt sichtbar.' } },
|
|
16
|
+
{ '@type': 'Question', name: 'Beweist ein positives Ergebnis die rechtliche Gültigkeit?', acceptedAnswer: { '@type': 'Answer', text: 'Nein. Prüfe Verfassung, Gesetz, amtliche Hinweise, Definitionen der Stimmzettel und die zuständige Zählstelle.' } },
|
|
17
|
+
] };
|
|
18
|
+
const howTo: HowTo = { '@type': 'HowTo', name: 'Referendumsquoren prüfen', step: [
|
|
19
|
+
{ '@type': 'HowToStep', name: 'Wählerkreis eintragen', text: 'Trage die Wahlberechtigten oder registrierten Wähler der geprüften Regel ein.' },
|
|
20
|
+
{ '@type': 'HowToStep', name: 'Stimmen erfassen', text: 'Trenne Ja, Nein, leere und ungültige Stimmzettel aus derselben Auszählung.' },
|
|
21
|
+
{ '@type': 'HowToStep', name: 'Beteiligung festlegen', text: 'Wähle die Zählweise und eine prozentuale oder absolute Beteiligungsschwelle.' },
|
|
22
|
+
{ '@type': 'HowToStep', name: 'Zustimmung festlegen', text: 'Wähle den Zustimmungsnenner und die erforderliche Ja-Zahl oder Ja-Quote.' },
|
|
23
|
+
{ '@type': 'HowToStep', name: 'Ergebnis lesen', text: 'Vergleiche Zähler, Nenner, beobachteten Wert, Schwelle und Datenhinweise.' },
|
|
24
|
+
] };
|
|
25
|
+
|
|
26
|
+
export const content: ToolLocaleContent<ReferendumThresholdLocaleContent['ui']> = {
|
|
27
|
+
slug: 'referendum-quorum-rechner', title: 'Referendum Quorum Rechner', description: 'Prüfe Beteiligungs- und Zustimmungsquoren mit sichtbaren Zählern, Nennern und klar getrennten Regeln.', ui, seo: [
|
|
28
|
+
{ type: 'title', text: 'Referendumsregel mit sichtbaren Nennern prüfen', level: 2 },
|
|
29
|
+
{ type: 'paragraph', html: 'Ein Referendum kann einfach aussehen, obwohl die entscheidende Frage im Nenner steckt. Dieser Rechner stellt Wahlkreis, Stimmzettel und beide Quoren nebeneinander, damit die verwendete Definition nachvollziehbar bleibt.' },
|
|
30
|
+
{ type: 'paragraph', html: 'Beteiligung und Zustimmung sind getrennte Prüfungen. Leere oder ungültige Stimmzettel können die Beteiligung erhöhen, ohne Teil der gültigen Stimmen zu sein. Das Werkzeug übernimmt diese Entscheidung nicht stillschweigend.' },
|
|
31
|
+
{ type: 'title', text: 'So funktionieren die beiden Quoren', level: 2 },
|
|
32
|
+
{ type: 'paragraph', html: 'Die Beteiligungsquote teilt die gewählte Beteiligungszahl durch die Wahlberechtigten. Die Zustimmungsquote teilt Ja durch den gewählten Zustimmungsnenner. Der Vergleich kann einen exakten Wert akzeptieren oder eine echte Überschreitung verlangen.' },
|
|
33
|
+
{ type: 'table', headers: ['Prüfung', 'Beobachteter Wert', 'Frage'], rows: [['Beteiligung', 'Ausgewählte Stimmzettel / Wahlberechtigte', 'Hat ein ausreichender Teil teilgenommen?'], ['Zustimmung', 'Ja / gewählter Nenner', 'Erreicht Ja die geforderte Schwelle?']] },
|
|
34
|
+
{ type: 'title', text: 'Zahlen sauber vorbereiten', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: 'Nutze Zahlen aus derselben amtlichen Auszählung und demselben Wählerkreis. Vermische keine vorläufige Zählung mit einem späteren Wählerverzeichnis. Halte Ja, Nein, leer und ungültig getrennt, bevor du einen Nenner auswählst.' },
|
|
36
|
+
{ type: 'list', items: ['Prüfe, ob die Regel Wahlberechtigte oder registrierte Wähler nennt.', 'Erfasse alle vier Stimmzettelkategorien separat.', 'Stelle sicher, dass die Stimmzettel den Wählerkreis nicht überschreiten.', 'Notiere den Nenner jeder Prozentangabe.', 'Bewahre den amtlichen Wortlaut neben dem Ergebnis auf.'] },
|
|
37
|
+
{ type: 'tip', title: 'Der Nenner ist Teil der Regel', html: '50 Prozent Zustimmung unter gültigen Stimmen ist eine andere Prüfung als 50 Prozent unter allen Stimmzetteln oder Wahlberechtigten. Leere und ungültige Stimmen können die Entscheidung verschieben.' },
|
|
38
|
+
{ type: 'title', text: 'Positive und negative Zustände einordnen', level: 2 },
|
|
39
|
+
{ type: 'paragraph', html: 'Das Ergebnis unterscheidet Beteiligungsfehler von Zustimmungsfehlern. Wenn die Beteiligung genügt, aber Zustimmung nicht, liegt das Problem bei Ja und seinem Nenner. Wenn nur Zustimmung genügt, fehlt die Beteiligungsbedingung.' },
|
|
40
|
+
{ type: 'paragraph', html: 'Ein positives Rechenergebnis ist keine Rechtsfeststellung. Länder und Fragestellungen können besondere Regeln, territoriale Bedingungen oder amtliche Behandlungen leerer Stimmen vorsehen. Geprüft wird nur deine Konfiguration.' },
|
|
41
|
+
{ type: 'tip', title: 'Das Ergebnis als Prüfbogen verwenden', html: 'Vergleiche vor einer Veröffentlichung jeden angezeigten Zähler, Nenner und Schwellentyp mit der aktuellen amtlichen Regel. Bei unklarer Definition solltest du den Hinweis offenlassen.' },
|
|
42
|
+
{ type: 'title', text: 'Quellen und Grenzen', level: 2 },
|
|
43
|
+
{ type: 'paragraph', html: 'International IDEA beschreibt die Bedeutung klarer Beteiligungs- und Zustimmungsregeln. Die Venedig-Kommission behandelt Wirkungen und Risiken von Quoren. Diese Quellen liefern keinen universellen Schwellenwert für jedes Land.' },
|
|
44
|
+
], faq: [
|
|
45
|
+
{ question: 'Was entscheidet dieser Rechner?', answer: 'Er prüft gemeldete Stimmen gegen die von dir eingegebenen Beteiligungs- und Zustimmungsschwellen. Er legt nicht fest, welche Rechtsregel gilt.' },
|
|
46
|
+
{ question: 'Was ist der Unterschied zwischen den beiden Quoren?', answer: 'Das Beteiligungsquorum fragt, ob genügend Stimmen oder Personen erfasst wurden. Das Zustimmungsquorum fragt, ob Ja die geforderte Zahl oder den geforderten Anteil erreicht.' },
|
|
47
|
+
{ question: 'Warum verändern leere und ungültige Stimmzettel das Ergebnis?', answer: 'Sie können bei der Beteiligung zählen, aber aus gültigen Stimmen ausgeschlossen werden. Der Rechner macht diese Nenner getrennt sichtbar.' },
|
|
48
|
+
{ question: 'Beweist ein positives Ergebnis die rechtliche Gültigkeit?', answer: 'Nein. Prüfe Verfassung, Gesetz, amtliche Hinweise, Definitionen der Stimmzettel und die zuständige Zählstelle.' },
|
|
49
|
+
], bibliography, howTo: [
|
|
50
|
+
{ name: 'Wählerkreis eintragen', text: 'Trage die Wahlberechtigten oder registrierten Wähler der geprüften Regel ein.' },
|
|
51
|
+
{ name: 'Stimmen erfassen', text: 'Trenne Ja, Nein, leere und ungültige Stimmzettel aus derselben Auszählung.' },
|
|
52
|
+
{ name: 'Beteiligung festlegen', text: 'Wähle die Zählweise und eine prozentuale oder absolute Beteiligungsschwelle.' },
|
|
53
|
+
{ name: 'Zustimmung festlegen', text: 'Wähle den Zustimmungsnenner und die erforderliche Ja-Zahl oder Ja-Quote.' },
|
|
54
|
+
{ name: 'Ergebnis lesen', text: 'Vergleiche Zähler, Nenner, beobachteten Wert, Schwelle und Datenhinweise.' },
|
|
55
|
+
], schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[]
|
|
56
|
+
};
|