diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..0ca2aad --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/bin/sh +# Runs before every `git push`. Mirrors the fast CI steps so failures are +# caught locally before they waste a CI run. +set -e + +echo "→ lint" +pnpm lint + +echo "→ typecheck" +pnpm typecheck + +echo "→ unit tests" +pnpm test + +echo "→ audit (high+)" +pnpm audit --audit-level high + +echo "✓ all checks passed" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7001f58 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2b8c39c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm audit --audit-level high + + - run: pnpm lint + + - run: pnpm typecheck + + - run: pnpm test + + - uses: astral-sh/setup-uv@v5 + + - name: Lint Python pipeline + run: uvx ruff check pipeline/scripts/ + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install Playwright browser + system deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright system deps (browser cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - run: pnpm test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/votations.yml b/.github/workflows/votations.yml new file mode 100644 index 0000000..792225d --- /dev/null +++ b/.github/workflows/votations.yml @@ -0,0 +1,37 @@ +name: Download votation results + +on: + workflow_dispatch: + inputs: + date: + description: 'Vote date (YYYYMMDD, e.g. 20260614)' + required: true + type: string + +jobs: + download: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Download votation data for ${{ inputs.date }} + run: uv run python pipeline/scripts/download_votations.py --add ${{ inputs.date }} + + - name: Open pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "data: add votation results for ${{ inputs.date }}" + title: "Add votation results for ${{ inputs.date }}" + body: | + Automated download of Swiss federal votation results for **${{ inputs.date }}**. + + Please verify the data looks correct before merging: + - Check `public/votations/${{ inputs.date }}.json` for expected proposal count and structure + - Check `public/votations/index.json` includes the new date + branch: "votation/${{ inputs.date }}" + delete-branch: true diff --git a/.gitignore b/.gitignore index 488504b..f8c2ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ # testing /coverage +/test-results +/playwright-report +/blob-report +/playwright/.cache # next.js /.next/ diff --git a/CLAUDE.md b/CLAUDE.md index 45c2f49..3bc814b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,12 +49,12 @@ swiss-maps/ │ ├── i18n.ts All UI translations (5 languages) │ └── utils.ts shadcn cn() helper ├── public/ -│ ├── geo/ Generated GeoJSON (gitignored, from pipeline) +│ ├── geo/ Generated GeoJSON (committed — regenerate with pipeline) │ │ ├── cantons.geojson │ │ ├── districts.geojson │ │ └── municipalities.geojson -│ └── votations/ Votation JSON files (gitignored, from pipeline) -│ ├── index.json ← COMMITTED — lists available dates +│ └── votations/ Votation JSON files (committed — static votation results) +│ ├── index.json ← lists available dates │ ├── 20260614.json │ ├── 20260308.json │ ├── 20251130.json @@ -175,6 +175,27 @@ pnpm build # production build pnpm typecheck # TypeScript check ``` +## Testing + +```bash +pnpm test # unit + component tests (Vitest + React Testing Library, run once) +pnpm test:watch # same, in watch mode +pnpm test:e2e # Playwright e2e — requires `pnpm dev` running with real pipeline data +``` + +- **Unit tests** (`lib/*.test.ts`) — pure functions in `lib/`, co-located with the source file. +- **Component tests** (`components/*.test.tsx`) — Vitest + React Testing Library, jsdom environment. + Use `renderWithProviders` from `test/test-utils.tsx` for components that call `useLanguage()` + (it wraps in `LanguageProvider`); plain `render` from `@testing-library/react` is fine for + components with no context dependency. +- **Config**: `vitest.config.ts` (jsdom env, `@` alias matches `tsconfig.json`, excludes `e2e/`), + `vitest.setup.ts` (jest-dom matchers + RTL `cleanup()` after each test). +- **E2E specs** (`e2e/*.spec.ts`) — Playwright, config in `playwright.config.ts`. Runs against the + local dev server (`pnpm dev`); `public/geo` and `public/votations` are committed so no pipeline + setup is needed. +- **CI** (`.github/workflows/ci.yml`) — runs `pnpm lint`, `pnpm typecheck`, `pnpm test`, and + `pnpm test:e2e` on push/PR. + ## Running the pipeline ```bash diff --git a/README.md b/README.md index 75c2162..17329e6 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ Interactive visualization of Swiss federal votation results and demographic stat - Defaults to the most recent past votation (skips future dates with no data) **Demographics map (`/demographics`)** -- 37 indicators across 10 groups: context (urban/periurban/rural typology), population, age, vital statistics (birth/death/marriage/divorce), land use, economy (employment by sector), housing, social, religion (2000 census), and politics +- 45 indicators across 11 groups: context (urban/periurban/rural typology), population, age, vital statistics (birth/death/marriage/divorce), land use, economy (employment by sector + taxable income), housing, social, religion (2000 census), language (official linguistic region + 2000 census home language), and politics - Urban/rural typology: categorical 3-class map (dark blue = urban, medium = periurban, light = rural) with swatch legend - Religion: Catholic %, Reformed %, Muslim %, no religion %, other — from 2000 census (last available at municipality level) +- Language: official linguistic region (German/French/Italian/Romansh, categorical) plus home-language shares from the 2000 census +- Income: median and average taxable income per commune (CHF, 2022) - Party votes shown individually plus a computed left–right index (diverging red↔blue scale) - Sequential blue scale for continuous indicators; P5–P95 domain per topic @@ -65,13 +67,31 @@ uv run python scripts/download_votations.py --add YYYYMMDD # add a new date # Trade data (BAZG official 2024 actuals — re-run to refresh) uv run python scripts/download_trade.py -# Demographic indicators — run all three, in order: -uv run python scripts/download_demographics.py # BFS Regionalportraits 2021 (30 indicators, 2019 data) +# Demographic indicators — run in order: +uv run python scripts/download_income.py # ESTV taxable income 2022 (median + average) +uv run python scripts/download_language.py # BFS linguistic region + 2000 census home language +uv run python scripts/download_demographics.py # BFS Regionalportraits 2021 + merges income/language/typology/religion uv run python scripts/download_typology.py # swisstopo urban/periurban/rural classification uv run python scripts/download_religion.py # BFS 2000 census religion data (2000 data only) ``` -See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator, reference year, and known limitations (income/unemployment/post-2000 religion are not available via API). +See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator, reference year, and known limitations (unemployment and post-2000 religion/language are not available via API). + +## Testing + +```bash +pnpm test # unit + component tests (Vitest + React Testing Library) +pnpm test:watch # same, in watch mode + +pnpm exec playwright install chromium # one-time browser install +pnpm dev # in one terminal — needs geo + votation data, see pipeline above +pnpm test:e2e # in another terminal (Playwright) +``` + +Unit/component tests are co-located with the source as `*.test.ts(x)` (e.g. `lib/votation.test.ts`, +`components/result-block.test.tsx`). E2E specs live in `e2e/*.spec.ts` and run against the local dev +server. CI runs lint, typecheck, and `pnpm test` on every push/PR; `pnpm test:e2e` is local-only for +now since it depends on the gitignored `public/geo` and `public/votations` pipeline output. ## Data sources @@ -82,6 +102,9 @@ See `pipeline/DATA_SOURCES.md` for a full breakdown of every dataset, indicator, - **Demographic indicators**: [BFS Regionalportraits 2021](https://opendata.swiss/en/dataset/regionalportrats-2021-kennzahlen-aller-gemeinden) — 30 indicators, ref. year 2019 - **Religion**: BFS Volkszählung 2000 (PxWeb `px-x-4003000000_122`) — **2000 data only**, no municipality-level religion data exists post-2000 - **Urban/rural typology**: swisstopo `g1a22` agglomeration shapefile — 3 classes (urban/periurban/rural) +- **Taxable income**: [ESTV Steuerstatistik der direkten Bundessteuer](https://www.estv.admin.ch) — nationwide municipality xlsx, tax year 2022, median + average per commune +- **Linguistic region**: BFS Raumgliederungen der Gemeinden (`agvchapp` API, `SPRGEB2020`) — current official German/French/Italian/Romansh language area per commune +- **Home language**: BFS Volkszählung 2000 (PxWeb `px-x-4003000000_123`) — **2000 data only**, same limitation as religion ## Tech stack diff --git a/TODO.md b/TODO.md index 25075d7..cefd2f5 100644 --- a/TODO.md +++ b/TODO.md @@ -9,4 +9,7 @@ - add email/link/feedback - clean erlauterungen text extraction with llm + add en translation - improve UI -- refactor code \ No newline at end of file +- refactor code +- add logs/analytics/monitoring +- set up CI data strategy (run pipeline in CI or commit minimal fixtures) so `pnpm test:e2e` can run in GitHub Actions +- add hooks \ No newline at end of file diff --git a/app/_favicon.ico b/app/_favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/_favicon.ico differ diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..995a01b 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index 18ad30f..238a347 100644 --- a/app/globals.css +++ b/app/globals.css @@ -59,7 +59,7 @@ --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); + --muted-foreground: oklch(0.50 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); diff --git a/app/layout.tsx b/app/layout.tsx index 5e0e586..20d0b46 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,16 @@ -import { Geist, Geist_Mono, Inter } from "next/font/google" +import type { Metadata } from "next" +import { Geist_Mono, Inter } from "next/font/google" import "./globals.css" import { ThemeProvider } from "@/components/theme-provider" import { LanguageProvider } from "@/contexts/language" import { cn } from "@/lib/utils"; +export const metadata: Metadata = { + title: 'Swiss Maps', + description: 'Interactive visualization of Swiss federal votation results, demographics, and trade statistics.', +} + const inter = Inter({subsets:['latin'],variable:'--font-sans'}) const fontMono = Geist_Mono({ diff --git a/components/app-header.test.tsx b/components/app-header.test.tsx new file mode 100644 index 0000000..e496a16 --- /dev/null +++ b/components/app-header.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { usePathname } from 'next/navigation' +import { renderWithProviders, screen } from '@/test/test-utils' +import { AppHeader } from './app-header' + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), +})) + +describe('AppHeader', () => { + it('renders the brand and all nav links', () => { + vi.mocked(usePathname).mockReturnValue('/') + renderWithProviders( {}} />) + expect(screen.getByText('Swiss Maps')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Votations' })).toHaveAttribute('href', '/') + expect(screen.getByRole('link', { name: 'Demographics' })).toHaveAttribute('href', '/demographics') + expect(screen.getByRole('link', { name: 'Trade' })).toHaveAttribute('href', '/trade') + expect(screen.getByRole('link', { name: 'Data' })).toHaveAttribute('href', '/data') + }) + + it('highlights the link matching the current path', () => { + vi.mocked(usePathname).mockReturnValue('/trade') + renderWithProviders( {}} />) + expect(screen.getByRole('link', { name: 'Trade' })).toHaveClass('text-foreground') + expect(screen.getByRole('link', { name: 'Votations' })).toHaveClass('text-muted-foreground') + }) + + it('calls onToggleSidebar when the mobile menu button is clicked', async () => { + vi.mocked(usePathname).mockReturnValue('/') + const onToggleSidebar = vi.fn() + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: 'Toggle sidebar' })) + expect(onToggleSidebar).toHaveBeenCalledOnce() + }) +}) diff --git a/components/app-header.tsx b/components/app-header.tsx index 09d3362..2e85929 100644 --- a/components/app-header.tsx +++ b/components/app-header.tsx @@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation' import { Map, Menu } from 'lucide-react' import { LanguageSwitcher } from './language-switcher' import { useLanguage } from '@/contexts/language' +import { Button } from './ui/button' interface AppHeaderProps { onToggleSidebar: () => void @@ -16,13 +17,15 @@ export function AppHeader({ onToggleSidebar }: AppHeaderProps) { return (
- + Swiss Maps diff --git a/components/app-sidebar.test.tsx b/components/app-sidebar.test.tsx new file mode 100644 index 0000000..5080d8f --- /dev/null +++ b/components/app-sidebar.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, screen } from '@/test/test-utils' +import { AppSidebar } from './app-sidebar' +import type { VotationData, VotationEntry, Resultat } from '@/lib/votation' +import type { DemographicData } from '@/lib/demographics' +import type { ErlaeuterungenData } from '@/lib/erlaeuterungen' + +const emptyResultat: Resultat = { + gebietAusgezaehlt: false, + jaStimmenInProzent: null, + jaStimmenAbsolut: null, + neinStimmenAbsolut: null, + stimmbeteiligungInProzent: null, + eingelegteStimmzettel: null, + anzahlStimmberechtigte: null, + gueltigeStimmen: null, +} + +function resultat(jaPct: number): Resultat { + return { ...emptyResultat, jaStimmenInProzent: jaPct, stimmbeteiligungInProzent: 40, gebietAusgezaehlt: true } +} + +const emptyStaende = { + jaStaendeGanz: null, + neinStaendeGanz: null, + anzahlStaendeGanz: null, + jaStaendeHalb: null, + neinStaendeHalb: null, + anzahlStaendeHalb: null, +} + +const votation: VotationData = { + abstimmtag: '2026-03-08', + timestamp: '2026-03-08T12:00:00Z', + vorlagen: [ + { + vorlagenId: 1, + vorlagenTitel: [{ langKey: 'en', text: 'Proposal One' }], + vorlagenArtId: 1, + doppeltesMehr: false, + vorlageAngenommen: true, + vorlageBeendet: true, + staende: emptyStaende, + resultat: resultat(60), + kantone: [], + }, + { + vorlagenId: 2, + vorlagenTitel: [{ langKey: 'en', text: 'Proposal Two' }], + vorlagenArtId: 2, + doppeltesMehr: false, + vorlageAngenommen: null, + vorlageBeendet: false, + staende: emptyStaende, + resultat: emptyResultat, + kantone: [], + }, + ], +} + +const index: VotationEntry[] = [ + { date: '20260308', label: 'Mar 2026', file: '20260308.json' }, + { date: '20251130', label: 'Nov 2025', file: '20251130.json' }, +] + +const demoData: DemographicData = { + meta: { source: 'test', reference_year: 2024, downloaded: '2026-01-01', url: 'https://example.com' }, + groups: [{ id: 'g1', label: { en: 'Demographics' } }], + topics: [ + { + id: 'pop_density', + group: 'g1', + label: { en: 'Population density' }, + unit: 'per km²', + color_scale: 'sequential', + domain: [0, 100], + source: 'test', + year: 2024, + }, + ], + communes: {}, + cantons: {}, +} + +const baseProps = { + isOpen: true, + onClose: () => {}, + index, + selectedDate: '20260308', + onSelectDate: () => {}, + votation: null as VotationData | null, + loadError: null as string | null, + selectedVorlageId: null as number | null, + onSelectVorlage: () => {}, + selection: null as { cantonNum: number; cantonName: string } | null, + cantonResult: null as Resultat | null, + cantonResults: null as Record | null, + municipalityResults: null as Record | null, + demoData: null as DemographicData | null, + erlaeuterungen: null as ErlaeuterungenData | null, +} + +describe('AppSidebar', () => { + it('shows an error message when loading failed', () => { + renderWithProviders() + expect(screen.getByText('Could not load votation data.')).toBeInTheDocument() + }) + + it('shows a loading message while votation data has not arrived', () => { + renderWithProviders() + expect(screen.getByText('Loading…')).toBeInTheDocument() + }) + + it('highlights the selected date among the date buttons', () => { + renderWithProviders() + expect(screen.getByText('Mar 2026')).toHaveClass('bg-primary') + expect(screen.getByText('Nov 2025')).not.toHaveClass('bg-primary') + }) + + it('calls onSelectDate and onClose when a date button is clicked', async () => { + const onSelectDate = vi.fn() + const onClose = vi.fn() + renderWithProviders() + await userEvent.click(screen.getByText('Nov 2025')) + expect(onSelectDate).toHaveBeenCalledWith('20251130') + expect(onClose).toHaveBeenCalledOnce() + }) + + it('renders proposal titles and types', () => { + renderWithProviders() + expect(screen.getByText('Proposal One')).toBeInTheDocument() + expect(screen.getByText('Mandatory referendum')).toBeInTheDocument() + expect(screen.getByText('Proposal Two')).toBeInTheDocument() + expect(screen.getByText('Optional referendum')).toBeInTheDocument() + }) + + it('calls onSelectVorlage and onClose when a proposal is clicked', async () => { + const onSelectVorlage = vi.fn() + const onClose = vi.fn() + renderWithProviders( + , + ) + await userEvent.click(screen.getByText('Proposal One')) + expect(onSelectVorlage).toHaveBeenCalledWith(1) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('only shows the explanations button for proposals with erlaeuterungen, and opens the modal', async () => { + const erlaeuterungen: ErlaeuterungenData = { + date: '20260308', + pdf_urls: {}, + proposals: [ + { + vorlagenId: 1, + title_de: '', + title_fr: '', + title_it: '', + title_en: '', + title_rm: '', + gov_rec: null, + inkuerze_de: 'Some summary', + inkuerze_fr: '', + inkuerze_it: '', + }, + ], + } + renderWithProviders() + const infoButtons = screen.getAllByTitle('Federal Council Explanations') + expect(infoButtons).toHaveLength(1) + + await userEvent.click(infoButtons[0]) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('shows the national result block for the selected proposal', () => { + renderWithProviders() + expect(screen.getByText('National result')).toBeInTheDocument() + expect(screen.getByText('60.0%')).toBeInTheDocument() + }) + + it('shows a "no data" message for the selected canton when no result is available', () => { + renderWithProviders( + , + ) + expect(screen.getByText(/Zürich/)).toBeInTheDocument() + expect(screen.getByText('No data')).toBeInTheDocument() + }) + + it('shows the canton result when available', () => { + renderWithProviders( + , + ) + expect(screen.getByText('45.0%')).toBeInTheDocument() + }) + + it('shows the correlation scatter when demographic data is available', () => { + renderWithProviders( + , + ) + expect(screen.getByText('Correlation')).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) +}) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 450a6f9..b94178e 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -3,17 +3,13 @@ import { useState } from 'react' import { CheckCircle, XCircle, Clock, ChevronRight, AlertCircle, Info } from 'lucide-react' import { useLanguage } from '@/contexts/language' -import { type VotationData, type Vorlage, type Resultat, getTitle, staendeYes } from '@/lib/votation' +import { type VotationData, type VotationEntry, type Vorlage, type Resultat, getTitle } from '@/lib/votation' import type { DemographicData } from '@/lib/demographics' import type { ErlaeuterungenData } from '@/lib/erlaeuterungen' import { VotationScatter } from './votation-scatter' import { VorlageInfoModal } from './vorlage-info-modal' - -interface VotationEntry { - date: string - label: string - file: string -} +import { ResultBlock, StaendeBlock } from './result-block' +import { SelectButton } from './ui/select-button' interface Selection { cantonNum: number @@ -38,82 +34,6 @@ interface AppSidebarProps { erlaeuterungen: ErlaeuterungenData | null } -function ResultBar({ pct }: { pct: number }) { - return ( -
-
= 50 ? 'bg-green-500' : 'bg-red-500'}`} - style={{ width: `${pct}%` }} - /> -
-
- ) -} - -function ResultBlock({ result }: { result: Resultat }) { - const { t } = useLanguage() - const { jaStimmenInProzent: ja, stimmbeteiligungInProzent: turnout, gebietAusgezaehlt } = result - - if (ja === null) { - return ( -
-

- {t.sidebar.pending} -

-
- ) - } - - return ( -
-
- {gebietAusgezaehlt && ( - {t.sidebar.final} - )} -
-
- = 50 ? 'text-green-600' : 'text-red-500'}`}> - {ja.toFixed(1)}% - - {t.sidebar.yes} -
- - {turnout !== null && ( -

- {t.sidebar.turnout}: {turnout.toFixed(1)}% -

- )} -
- ) -} - -function StaendeBlock({ vorlage }: { vorlage: Vorlage }) { - const { t } = useLanguage() - if (!vorlage.doppeltesMehr) return null - const { staende } = vorlage - const yes = staendeYes(staende) - const no = (staende.neinStaendeGanz ?? 0) + (staende.neinStaendeHalb ?? 0) * 0.5 - const pending = staende.anzahlStaendeGanz === null - - return ( -
-

{t.sidebar.cantonalVotes}

- {pending ? ( -

- {t.sidebar.pending} -

- ) : ( -
- {yes} {t.sidebar.yes} - / - {no} {t.sidebar.no} - {t.sidebar.cantonalVotesOf} -
- )} -
- ) -} - export function AppSidebar({ isOpen, onClose, @@ -152,17 +72,15 @@ export function AppSidebar({

{index.map((entry) => ( - + ))}
diff --git a/components/data-layout-elements.test.tsx b/components/data-layout-elements.test.tsx new file mode 100644 index 0000000..de8c8f2 --- /dev/null +++ b/components/data-layout-elements.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Section, Field, Tag, LimitationList, Callout, TAG_COLORS } from './data-layout-elements' + +describe('Section', () => { + it('renders a heading and its children', () => { + render( +
+

Section body

+
, + ) + expect(screen.getByRole('heading', { name: 'Votations' })).toBeInTheDocument() + expect(screen.getByText('Section body')).toBeInTheDocument() + }) +}) + +describe('Field', () => { + it('renders a label and its children', () => { + render(BFS / opendata.swiss) + expect(screen.getByText('Source')).toBeInTheDocument() + expect(screen.getByText('BFS / opendata.swiss')).toBeInTheDocument() + }) +}) + +describe('Tag', () => { + it('applies the color classes for each known color', () => { + for (const color of Object.keys(TAG_COLORS) as Array) { + const { unmount } = render({color}) + const classes = TAG_COLORS[color].split(' ') + expect(screen.getByText(color)).toHaveClass(...classes) + unmount() + } + }) +}) + +describe('LimitationList', () => { + it('renders one list item per limitation', () => { + render() + const items = screen.getAllByRole('listitem') + expect(items).toHaveLength(2) + expect(screen.getByText('First limitation')).toBeInTheDocument() + expect(screen.getByText('Second limitation')).toBeInTheDocument() + }) +}) + +describe('Callout', () => { + it('renders its children', () => { + render(Important note) + expect(screen.getByText('Important note')).toBeInTheDocument() + }) +}) diff --git a/components/data-layout-elements.tsx b/components/data-layout-elements.tsx new file mode 100644 index 0000000..6d2f0c5 --- /dev/null +++ b/components/data-layout-elements.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { Badge } from './ui/badge' + +export const TAG_COLORS = { + green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', + amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', + blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', +} as const + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +export function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +export function Tag({ color, children }: { color: keyof typeof TAG_COLORS; children: ReactNode }) { + return {children} +} + +export function LimitationList({ items }: { items: string[] }) { + return ( +
    + {items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ ) +} + +export function Callout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/components/data-layout.tsx b/components/data-layout.tsx index 467f41a..55433a5 100644 --- a/components/data-layout.tsx +++ b/components/data-layout.tsx @@ -2,58 +2,10 @@ import { useState } from 'react' import { AppHeader } from './app-header' - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ) -} - -function Tag({ color, children }: { color: 'green' | 'amber' | 'red' | 'blue'; children: React.ReactNode }) { - const cls = { - green: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', - amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', - red: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', - blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', - }[color] - return {children} -} - -function LimitationList({ items }: { items: string[] }) { - return ( -
    - {items.map((item, i) => ( -
  • - - {item} -
  • - ))} -
- ) -} - -function Callout({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ) -} +import { Section, Field, Tag, LimitationList, Callout } from './data-layout-elements' export function DataLayout() { - const [sidebarOpen, setSidebarOpen] = useState(false) + const [, setSidebarOpen] = useState(false) return (
@@ -62,14 +14,14 @@ export function DataLayout() {

Data Sources

- Every dataset used in Swiss Maps — where it comes from, how fresh it is, what it covers, and what it doesn't. + Every dataset used in Swiss Maps — where it comes from, how fresh it is, what it covers, and what it doesn't.

{/* ── VOTATIONS ── */}
- BFS / opendata.swiss {' — '} official federal votation results @@ -182,7 +134,7 @@ export function DataLayout() {

Commune mergers and renumbering between 2000 and 2022 mean approximately 5–10% of - BFS numbers won't match the current boundary file. Those communes appear grey + BFS numbers won't match the current boundary file. Those communes appear grey (no data) on the religion map. @@ -237,7 +189,7 @@ export function DataLayout() {
-

What is "Business cycle total"?

+

What is "Business cycle total"?

BAZG publishes two trade totals. This app uses the business cycle total (CHF 283B exports). The broader general total (CHF 394B) adds precious metals, rough diamonds, gemstones, @@ -246,7 +198,7 @@ export function DataLayout() { Excluding them gives a clearer picture of the productive economy.

- The CHF ~111B gap is almost entirely gold. Switzerland is one of the world's largest gold refining + The CHF ~111B gap is almost entirely gold. Switzerland is one of the world's largest gold refining and trading hubs — significant economic activity, but it inflates bilateral trade figures in ways that distort a geographic visualization. diff --git a/components/demographics-map.tsx b/components/demographics-map.tsx index 73cafb6..4cb07d0 100644 --- a/components/demographics-map.tsx +++ b/components/demographics-map.tsx @@ -8,32 +8,37 @@ import type { ExpressionSpecification, StyleSpecification } from 'maplibre-gl' import type { FeatureCollection } from 'geojson' import type { DemographicTopic } from '@/lib/demographics' import { useLanguage } from '@/contexts/language' -import { loadStrippedStyle, MAP_STYLE_URL } from '@/lib/map-style' +import { loadMapStyle, MAP_STYLE_URL, HOVER_OPACITY } from '@/lib/map-style' +import { MapTooltip } from './map-tooltip' const SWISS_BOUNDS: [[number, number], [number, number]] = [ [5.96, 45.82], [10.49, 47.81], ] const NO_DATA = -9999 -const HOVER_OPACITY: ExpressionSpecification = [ - 'case', ['boolean', ['feature-state', 'hover'], false], 0.95, 0.8, -] +// Per-topic colour palettes for categorical scales, keyed by category value +const CATEGORY_PALETTES: Record> = { + urban_rural: { 1: '#1d4ed8', 2: '#60a5fa', 3: '#bfdbfe' }, + official_language_region: { 1: '#2563eb', 2: '#dc2626', 3: '#16a34a', 4: '#9333ea' }, +} +const DEFAULT_CATEGORY_COLORS = ['#1d4ed8', '#60a5fa', '#bfdbfe', '#fbbf24', '#9333ea'] -// Colors for the 3-class typology -const TYPOLOGY_COLORS: Record = { 1: '#1d4ed8', 2: '#60a5fa', 3: '#bfdbfe' } +function getCategoryColor(topicId: string, key: number): string { + return CATEGORY_PALETTES[topicId]?.[key] ?? DEFAULT_CATEGORY_COLORS[key - 1] ?? '#94a3b8' +} function buildPaint(topic: DemographicTopic): ExpressionSpecification { const [lo, hi] = topic.domain const noDataCheck: ExpressionSpecification = ['<=', ['get', 'demo_value'], NO_DATA + 1] if (topic.color_scale === 'categorical') { - return [ - 'match', ['round', ['get', 'demo_value']], - 1, TYPOLOGY_COLORS[1], - 2, TYPOLOGY_COLORS[2], - 3, TYPOLOGY_COLORS[3], - '#cbd5e1', - ] + const keys = topic.categories ? Object.keys(topic.categories).map(Number) : [] + const matchExpr: unknown[] = ['match', ['round', ['get', 'demo_value']]] + for (const k of keys) { + matchExpr.push(k, getCategoryColor(topic.id, k)) + } + matchExpr.push('#cbd5e1') + return matchExpr as unknown as ExpressionSpecification } if (topic.color_scale === 'diverging') { @@ -96,7 +101,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp const [tooltip, setTooltip] = useState(null) useEffect(() => { - loadStrippedStyle().then(setBaseStyle) + loadMapStyle(true).then(setBaseStyle) }, []) useEffect(() => { @@ -155,6 +160,9 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp const key = String(Math.round(value)) return topic.categories[key]?.[lang] ?? topic.categories[key]?.['en'] ?? String(value) } + if (topic?.unit === 'CHF') { + return `${Math.round(value).toLocaleString(lang)} CHF` + } return `${value.toFixed(1)}${topic?.unit ? ` ${topic.unit}` : ''}` } @@ -187,10 +195,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp {/* Tooltip */} {tooltip && ( -
+

{tooltip.name}

{tooltip.value > NO_DATA ? (

@@ -199,7 +204,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp ) : (

)} -
+ )} {/* Legend */} @@ -212,7 +217,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp
{labels[lang] ?? labels['en']}
@@ -220,13 +225,13 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp
) : (
- {topic.domain[0].toFixed(1)} + {formatValue(topic.domain[0])} {isDiverging ? (
) : (
)} - {topic.domain[1].toFixed(1)}{topic.unit ? ` ${topic.unit}` : ''} + {formatValue(topic.domain[1])}
)} {isDiverging && ( diff --git a/components/demographics-sidebar.test.tsx b/components/demographics-sidebar.test.tsx new file mode 100644 index 0000000..f6308f5 --- /dev/null +++ b/components/demographics-sidebar.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, screen } from '@/test/test-utils' +import { DemographicsSidebar } from './demographics-sidebar' +import type { DemographicData } from '@/lib/demographics' + +const demoData: DemographicData = { + meta: { source: 'test', reference_year: 2024, downloaded: '2026-01-01', url: 'https://example.com' }, + groups: [ + { id: 'g1', label: { en: 'Population' } }, + { id: 'g2', label: { en: 'Economy' } }, + ], + topics: [ + { + id: 'pop_density', + group: 'g1', + label: { en: 'Population density' }, + unit: 'per km²', + color_scale: 'sequential', + domain: [0, 100], + source: 'BFS', + year: 2021, + }, + { + id: 'income', + group: 'g2', + label: { en: 'Income' }, + unit: 'CHF', + color_scale: 'sequential', + domain: [0, 100], + source: 'ESTV', + year: 2022, + }, + ], + communes: {}, + cantons: {}, +} + +const baseProps = { + isOpen: true, + onClose: () => {}, + data: null as DemographicData | null, + loadError: null as string | null, + selectedTopicId: null as string | null, + onSelectTopic: () => {}, +} + +describe('DemographicsSidebar', () => { + it('shows a loading message while data has not arrived yet', () => { + renderWithProviders() + expect(screen.getByText('Loading…')).toBeInTheDocument() + }) + + it('shows an error message when loading failed', () => { + renderWithProviders() + expect(screen.getByText('Could not load demographic data.')).toBeInTheDocument() + expect(screen.queryByText('Loading…')).not.toBeInTheDocument() + }) + + it('renders topics grouped by their group label', () => { + renderWithProviders() + expect(screen.getByText('Population')).toBeInTheDocument() + expect(screen.getByText('Population density')).toBeInTheDocument() + expect(screen.getByText('Economy')).toBeInTheDocument() + expect(screen.getByText('Income')).toBeInTheDocument() + }) + + it('calls onSelectTopic when a topic is clicked', async () => { + const onSelectTopic = vi.fn() + renderWithProviders() + await userEvent.click(screen.getByText('Income')) + expect(onSelectTopic).toHaveBeenCalledWith('income') + }) + + it('highlights the selected topic', () => { + renderWithProviders() + expect(screen.getByText('Income')).toHaveClass('text-primary') + expect(screen.getByText('Population density')).not.toHaveClass('text-primary') + }) + + it('shows the source and year for the selected topic', () => { + renderWithProviders() + expect(screen.getByText(/Source: BFS \(2021\)/)).toBeInTheDocument() + }) + + it('calls onClose when the mobile close button is clicked', async () => { + const onClose = vi.fn() + renderWithProviders() + await userEvent.click(screen.getAllByRole('button')[0]) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/components/demographics-sidebar.tsx b/components/demographics-sidebar.tsx index fea41a9..753f097 100644 --- a/components/demographics-sidebar.tsx +++ b/components/demographics-sidebar.tsx @@ -3,6 +3,7 @@ import { X } from 'lucide-react' import { useLanguage } from '@/contexts/language' import type { DemographicData, DemographicGroup, DemographicTopic } from '@/lib/demographics' +import { Button } from './ui/button' interface DemographicsSidebarProps { isOpen: boolean @@ -44,12 +45,12 @@ export function DemographicsSidebar({ >
{t.demographics.title} - +
-
+
{loadError && (

{t.demographics.error} @@ -77,7 +78,6 @@ export function DemographicsSidebar({

))} -
{selectedTopic && ( diff --git a/components/language-switcher.test.tsx b/components/language-switcher.test.tsx new file mode 100644 index 0000000..a17f620 --- /dev/null +++ b/components/language-switcher.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, screen } from '@/test/test-utils' +import { LanguageSwitcher } from './language-switcher' +import { LANGS } from '@/lib/i18n' + +describe('LanguageSwitcher', () => { + it('renders one button per supported language', () => { + renderWithProviders() + for (const { label } of LANGS) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument() + } + }) + + it('defaults to English as the active language', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: 'EN' })).toHaveClass('bg-primary') + expect(screen.getByRole('button', { name: 'DE' })).not.toHaveClass('bg-primary') + }) + + it('marks a language as active once clicked', async () => { + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: 'DE' })) + expect(screen.getByRole('button', { name: 'DE' })).toHaveClass('bg-primary') + expect(screen.getByRole('button', { name: 'EN' })).not.toHaveClass('bg-primary') + }) +}) diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index bd5c5a8..71c2cb5 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -2,23 +2,22 @@ import { LANGS } from '@/lib/i18n' import { useLanguage } from '@/contexts/language' +import { SelectButton } from './ui/select-button' export function LanguageSwitcher() { const { lang, setLang } = useLanguage() return (
{LANGS.map(({ code, label }) => ( - + ))}
) diff --git a/components/map-shell.tsx b/components/map-shell.tsx index 84ca0d9..164820a 100644 --- a/components/map-shell.tsx +++ b/components/map-shell.tsx @@ -5,6 +5,7 @@ import { AppSidebar } from './app-sidebar' import { MapLoader } from './map-loader' import { type VotationData, + type VotationEntry, type Resultat, fetchVotation, buildCantonResultMap, @@ -14,12 +15,6 @@ import { import { fetchDemographics, type DemographicData } from '@/lib/demographics' import { fetchErlaeuterungen, type ErlaeuterungenData } from '@/lib/erlaeuterungen' -interface VotationEntry { - date: string - label: string - file: string -} - interface MapShellProps { sidebarOpen: boolean onCloseSidebar: () => void @@ -39,6 +34,17 @@ export function MapShell({ sidebarOpen, onCloseSidebar }: MapShellProps) { const [selection, setSelection] = useState(null) const [demoData, setDemoData] = useState(null) const [erlaeuterungen, setErlaeuterungen] = useState(null) + const [loadedDate, setLoadedDate] = useState(null) + + // Reset previous votation data as soon as the selected date changes, before the + // new data has loaded (adjusting state during render avoids an extra cascading effect). + if (selectedDate !== loadedDate) { + setLoadedDate(selectedDate) + setVotation(null) + setSelectedVorlageId(null) + setLoadError(null) + setErlaeuterungen(null) + } // Load demographics in background (non-blocking) useEffect(() => { @@ -64,11 +70,6 @@ export function MapShell({ sidebarOpen, onCloseSidebar }: MapShellProps) { const entry = index.find((e) => e.date === selectedDate) if (!entry) return - setVotation(null) - setSelectedVorlageId(null) - setLoadError(null) - setErlaeuterungen(null) - fetchVotation(entry.file) .then((data) => { setVotation(data) diff --git a/components/map-tooltip.test.tsx b/components/map-tooltip.test.tsx new file mode 100644 index 0000000..2d26b73 --- /dev/null +++ b/components/map-tooltip.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MapTooltip } from './map-tooltip' + +describe('MapTooltip', () => { + it('renders its children', () => { + render( + + Zürich + , + ) + expect(screen.getByText('Zürich')).toBeInTheDocument() + }) + + it('positions itself offset from the given coordinates', () => { + const { container } = render( + + Zürich + , + ) + const tooltip = container.firstChild as HTMLElement + expect(tooltip.style.left).toBe('112px') + expect(tooltip.style.top).toBe('10px') + }) +}) diff --git a/components/map-tooltip.tsx b/components/map-tooltip.tsx new file mode 100644 index 0000000..64f57e5 --- /dev/null +++ b/components/map-tooltip.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' + +interface MapTooltipProps { + x: number + y: number + children: ReactNode +} + +/** Small popover anchored to a map hover position (cantons/districts/municipalities + demographics). */ +export function MapTooltip({ x, y, children }: MapTooltipProps) { + return ( +
+ {children} +
+ ) +} diff --git a/components/result-block.test.tsx b/components/result-block.test.tsx new file mode 100644 index 0000000..9426fe5 --- /dev/null +++ b/components/result-block.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { renderWithProviders, screen } from '@/test/test-utils' +import { ResultBar, ResultBlock, StaendeBlock } from './result-block' +import type { Resultat, Vorlage } from '@/lib/votation' + +const emptyResultat: Resultat = { + gebietAusgezaehlt: false, + jaStimmenInProzent: null, + jaStimmenAbsolut: null, + neinStimmenAbsolut: null, + stimmbeteiligungInProzent: null, + eingelegteStimmzettel: null, + anzahlStimmberechtigte: null, + gueltigeStimmen: null, +} + +describe('ResultBar', () => { + it('uses a green bar and the percentage as width when >= 50', () => { + const { container } = renderWithProviders() + const bar = container.querySelector('.bg-green-500') as HTMLElement + expect(bar).toBeInTheDocument() + expect(bar.style.width).toBe('62%') + }) + + it('uses a red bar when < 50', () => { + const { container } = renderWithProviders() + expect(container.querySelector('.bg-red-500')).toBeInTheDocument() + }) + + it('uses a green bar at exactly 50% (boundary: pct >= 50 is green)', () => { + const { container } = renderWithProviders() + expect(container.querySelector('.bg-green-500')).toBeInTheDocument() + }) +}) + +describe('ResultBlock', () => { + it('shows a pending state when jaStimmenInProzent is null', () => { + renderWithProviders() + expect(screen.getByText('Pending')).toBeInTheDocument() + }) + + it('shows the yes percentage, final badge, and turnout when available', () => { + const result: Resultat = { + ...emptyResultat, + jaStimmenInProzent: 60, + stimmbeteiligungInProzent: 45, + gebietAusgezaehlt: true, + } + renderWithProviders() + expect(screen.getByText('60.0%')).toHaveClass('text-green-600') + expect(screen.getByText('Final')).toBeInTheDocument() + expect(screen.getByText('Turnout: 45.0%')).toBeInTheDocument() + }) + + it('uses the red color for a no-majority result', () => { + const result: Resultat = { ...emptyResultat, jaStimmenInProzent: 40 } + renderWithProviders() + expect(screen.getByText('40.0%')).toHaveClass('text-red-500') + }) + + it('shows the percentage but not a "Final" badge when counting is still in progress', () => { + const result: Resultat = { ...emptyResultat, jaStimmenInProzent: 55, gebietAusgezaehlt: false } + renderWithProviders() + expect(screen.getByText('55.0%')).toBeInTheDocument() + expect(screen.queryByText('Final')).not.toBeInTheDocument() + }) + + it('applies the green color at exactly 50% (boundary: ja >= 50 is green)', () => { + const result: Resultat = { ...emptyResultat, jaStimmenInProzent: 50 } + renderWithProviders() + expect(screen.getByText('50.0%')).toHaveClass('text-green-600') + }) +}) + +const vorlage: Vorlage = { + vorlagenId: 1, + vorlagenTitel: [{ langKey: 'de', text: 'Vorlage' }], + vorlagenArtId: 1, + doppeltesMehr: true, + vorlageAngenommen: null, + vorlageBeendet: false, + staende: { + jaStaendeGanz: 10, + neinStaendeGanz: 12, + anzahlStaendeGanz: 20, + jaStaendeHalb: 1, + neinStaendeHalb: 1, + anzahlStaendeHalb: 6, + }, + resultat: emptyResultat, + kantone: [], +} + +describe('StaendeBlock', () => { + it('renders nothing when doppeltesMehr is false', () => { + const { container } = renderWithProviders() + expect(container).toBeEmptyDOMElement() + }) + + it('shows a pending state when the cantonal count has not started', () => { + const pendingVorlage: Vorlage = { + ...vorlage, + staende: { ...vorlage.staende, anzahlStaendeGanz: null }, + } + renderWithProviders() + expect(screen.getByText('Pending')).toBeInTheDocument() + }) + + it('shows yes/no cantonal counts, with half-cantons counted as 0.5', () => { + renderWithProviders() + expect(screen.getByText('10.5 Yes')).toBeInTheDocument() + expect(screen.getByText('12.5 No')).toBeInTheDocument() + expect(screen.getByText('of 23')).toBeInTheDocument() + }) +}) diff --git a/components/result-block.tsx b/components/result-block.tsx new file mode 100644 index 0000000..2931bfd --- /dev/null +++ b/components/result-block.tsx @@ -0,0 +1,81 @@ +'use client' + +import { Clock } from 'lucide-react' +import { useLanguage } from '@/contexts/language' +import { type Vorlage, type Resultat, staendeYes } from '@/lib/votation' + +export function ResultBar({ pct }: { pct: number }) { + return ( +
+
= 50 ? 'bg-green-500' : 'bg-red-500'}`} + style={{ width: `${pct}%` }} + /> +
+
+ ) +} + +export function ResultBlock({ result }: { result: Resultat }) { + const { t } = useLanguage() + const { jaStimmenInProzent: ja, stimmbeteiligungInProzent: turnout, gebietAusgezaehlt } = result + + if (ja === null) { + return ( +
+

+ {t.sidebar.pending} +

+
+ ) + } + + return ( +
+
+ {gebietAusgezaehlt && ( + {t.sidebar.final} + )} +
+
+ = 50 ? 'text-green-600' : 'text-red-500'}`}> + {ja.toFixed(1)}% + + {t.sidebar.yes} +
+ + {turnout !== null && ( +

+ {t.sidebar.turnout}: {turnout.toFixed(1)}% +

+ )} +
+ ) +} + +export function StaendeBlock({ vorlage }: { vorlage: Vorlage }) { + const { t } = useLanguage() + if (!vorlage.doppeltesMehr) return null + const { staende } = vorlage + const yes = staendeYes(staende) + const no = (staende.neinStaendeGanz ?? 0) + (staende.neinStaendeHalb ?? 0) * 0.5 + const pending = staende.anzahlStaendeGanz === null + + return ( +
+

{t.sidebar.cantonalVotes}

+ {pending ? ( +

+ {t.sidebar.pending} +

+ ) : ( +
+ {yes} {t.sidebar.yes} + / + {no} {t.sidebar.no} + {t.sidebar.cantonalVotesOf} +
+ )} +
+ ) +} diff --git a/components/swiss-map.tsx b/components/swiss-map.tsx index 68b1b4f..4bb6729 100644 --- a/components/swiss-map.tsx +++ b/components/swiss-map.tsx @@ -4,79 +4,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Map, { Layer, Source } from 'react-map-gl/maplibre' import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' import 'maplibre-gl/dist/maplibre-gl.css' -import type { ExpressionSpecification, StyleSpecification } from 'maplibre-gl' +import type { StyleSpecification } from 'maplibre-gl' import type { FeatureCollection } from 'geojson' import type { Resultat } from '@/lib/votation' import { useLanguage } from '@/contexts/language' -import { loadStrippedStyle, MAP_STYLE_URL } from '@/lib/map-style' +import { loadMapStyle, MAP_STYLE_URL, CHOROPLETH_COLOR, HOVER_OPACITY } from '@/lib/map-style' +import { featureBounds, mergeResults } from '@/lib/map-utils' +import { MapTooltip } from './map-tooltip' + const SWISS_BOUNDS: [[number, number], [number, number]] = [ [5.96, 45.82], [10.49, 47.81], ] -function featureBounds(geometry: GeoJSON.Geometry): [[number, number], [number, number]] { - const lngs: number[] = [] - const lats: number[] = [] - function walk(c: unknown) { - if (Array.isArray(c) && typeof c[0] === 'number') { - lngs.push(c[0] as number) - lats.push(c[1] as number) - } else if (Array.isArray(c)) { - c.forEach(walk) - } - } - walk((geometry as { coordinates: unknown }).coordinates) - return [ - [Math.min(...lngs), Math.min(...lats)], - [Math.max(...lngs), Math.max(...lats)], - ] -} - -/** Merges result data into a GeoJSON FeatureCollection. - * ja_pct = -1 is the sentinel for "no data" used in paint expressions. */ -function mergeResults( - collection: FeatureCollection, - results: Record | null, - keyProp: string, -): FeatureCollection { - if (!results) return collection - return { - ...collection, - features: collection.features.map((f) => { - const r = results[f.properties?.[keyProp] as number] - return { - ...f, - properties: { - ...f.properties, - ja_pct: r?.jaStimmenInProzent ?? -1, - turnout: r?.stimmbeteiligungInProzent ?? -1, - ausgezaehlt: r?.gebietAusgezaehlt ?? false, - }, - } - }), - } -} - -// No-data: light slate. Scale: strong red → slate-400 midpoint → strong green. -// Midpoint is a visible neutral (not white) so 40% vs 60% are clearly distinct. -const CHOROPLETH_COLOR: ExpressionSpecification = [ - 'case', - ['<', ['get', 'ja_pct'], 0], - '#cbd5e1', - [ - 'interpolate', ['linear'], ['get', 'ja_pct'], - 0, '#b91c1c', - 35, '#f87171', - 50, '#94a3b8', - 65, '#4ade80', - 100, '#15803d', - ], -] - -const HOVER_OPACITY: ExpressionSpecification = [ - 'case', ['boolean', ['feature-state', 'hover'], false], 0.95, 0.8, -] - interface TooltipState { x: number y: number @@ -119,7 +59,7 @@ export default function SwissMap({ const [tooltip, setTooltip] = useState(null) useEffect(() => { - loadStrippedStyle().then(setBaseStyle) + loadMapStyle(true).then(setBaseStyle) }, []) useEffect(() => { @@ -212,7 +152,7 @@ export default function SwissMap({ setTooltip(null) map.getCanvas().style.cursor = '' } - }, [cantonResults, districtResults, municipalityResults, selectedCantonNum, setHover, clearHover]) + }, [districtResults, municipalityResults, selectedCantonNum, setHover, clearHover]) const onMouseLeave = useCallback(() => { clearHover() @@ -329,10 +269,7 @@ export default function SwissMap({ {tooltip && ( -
+

{tooltip.name}

{tooltip.ja_pct >= 0 ? ( @@ -349,7 +286,7 @@ export default function SwissMap({ {t.map.pending} )}
-
+ )} {selectedCantonNum !== null && ( diff --git a/components/trade-map.tsx b/components/trade-map.tsx index 1a7532d..53fe835 100644 --- a/components/trade-map.tsx +++ b/components/trade-map.tsx @@ -5,10 +5,9 @@ import Map from 'react-map-gl/maplibre' import type { MapRef } from 'react-map-gl/maplibre' import 'maplibre-gl/dist/maplibre-gl.css' import type { StyleSpecification } from 'maplibre-gl' -import { loadWorldStyle, MAP_STYLE_URL } from '@/lib/map-style' +import { loadMapStyle, MAP_STYLE_URL } from '@/lib/map-style' import type { TradePartner, TradeData, FtaStatus, SectorsData } from '@/lib/trade' -import { FTA_LABELS, sectorMetrics } from '@/lib/trade' -import { SECTORS } from './trade-sidebar' +import { FTA_LABELS, SECTORS, sectorMetrics } from '@/lib/trade' import { useLanguage } from '@/contexts/language' const CH_CENTROID: [number, number] = [8.2, 46.8] @@ -60,12 +59,12 @@ export default function TradeMap({ const { t } = useLanguage() const mapRef = useRef(null) const [baseStyle, setBaseStyle] = useState(MAP_STYLE_URL) - const [mapReady, setMapReady] = useState(false) + const [map, setMap] = useState(null) const [showAll, setShowAll] = useState(false) const [tooltip, setTooltip] = useState(null) const [, forceUpdate] = useReducer((x: number) => x + 1, 0) - useEffect(() => { loadWorldStyle().then(setBaseStyle) }, []) + useEffect(() => { loadMapStyle().then(setBaseStyle) }, []) const partners = ftaFilter === 'all' ? data.partners @@ -83,7 +82,6 @@ export default function TradeMap({ ? Math.max(...visiblePartners.map(p => sectorMetrics(p, sectorsData.by_country, sectorFilter).volume), 1) : 1 - const map = mapReady ? mapRef.current?.getMap() : undefined const chProjected = map ? map.project(CH_CENTROID) : null @@ -93,7 +91,7 @@ export default function TradeMap({ ref={mapRef} mapStyle={baseStyle} initialViewState={{ bounds: WORLD_BOUNDS, fitBoundsOptions: { padding: 20 } }} - onLoad={() => setMapReady(true)} + onLoad={() => setMap(mapRef.current?.getMap() ?? null)} onMove={forceUpdate} style={{ width: '100%', height: '100%' }} /> @@ -136,9 +134,6 @@ export default function TradeMap({ const proj = map.project(partner.centroid) const isHovered = hoveredCode === partner.country_code const isSelected = selectedCode === partner.country_code - const sm = sectorFilter && sectorsData - ? sectorMetrics(partner, sectorsData.by_country, sectorFilter) - : null const dotColor = sectorFilter ? (SECTORS.find(s => s.code === sectorFilter)?.color ?? '#94a3b8') : (partner.balance >= 0 ? '#16a34a' : '#dc2626') diff --git a/components/trade-partner-card.test.tsx b/components/trade-partner-card.test.tsx new file mode 100644 index 0000000..a3a033d --- /dev/null +++ b/components/trade-partner-card.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PartnerCard } from './trade-partner-card' +import { translations } from '@/lib/i18n' +import type { TradePartner, SectorsData } from '@/lib/trade' + +const partner: TradePartner = { + country: 'Germany', + country_code: 'DE', + exports: 1000, + imports: 800, + balance: 200, + fta_status: 'EU_bilateral', +} + +describe('PartnerCard', () => { + it('renders the country name, FTA status, and formatted trade figures', () => { + render( {}} t={translations.en} />) + expect(screen.getByText('Germany')).toBeInTheDocument() + expect(screen.getByText('EU Bilateral')).toBeInTheDocument() + expect(screen.getByText('1.0B')).toBeInTheDocument() // exports + expect(screen.getByText('800M')).toBeInTheDocument() // imports + expect(screen.getByText('200M')).toBeInTheDocument() // balance + }) + + it('calls onClose when the close button is clicked', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('does not render sector breakdowns when no sector data is available', () => { + render( {}} t={translations.en} />) + expect(screen.queryByText('Exports by sector')).not.toBeInTheDocument() + expect(screen.queryByText('Imports by sector')).not.toBeInTheDocument() + }) + + it('renders sector breakdowns when sector data is available for the country', () => { + const sectorsData: SectorsData = { + metadata: { source: 'test', year: 2024, note: '' }, + by_country: { + DE: { + exports: [{ sector: 'Pharma', sector_code: 'CHEM_PHARMA', share_pct: 25, value_CHF_millions: 250 }], + imports: [{ sector: 'Vehicles', sector_code: 'VEHICLES', share_pct: 15, value_CHF_millions: 120 }], + }, + }, + } + render( {}} t={translations.en} />) + expect(screen.getByText('Exports by sector')).toBeInTheDocument() + expect(screen.getByText('Imports by sector')).toBeInTheDocument() + expect(screen.getByText('Pharma')).toBeInTheDocument() + expect(screen.getByText('Vehicles')).toBeInTheDocument() + }) +}) diff --git a/components/trade-partner-card.tsx b/components/trade-partner-card.tsx new file mode 100644 index 0000000..861be8a --- /dev/null +++ b/components/trade-partner-card.tsx @@ -0,0 +1,65 @@ +import { X } from 'lucide-react' +import type { TradePartner, SectorsData } from '@/lib/trade' +import { FTA_LABELS, SECTORS } from '@/lib/trade' +import { fmtB } from '@/lib/trade-format' +import { useLanguage } from '@/contexts/language' +import { Button } from './ui/button' +import { SectorBar } from './trade-sector-bar' + +export function PartnerCard({ partner, sectorsData, onClose, t }: { + partner: TradePartner + sectorsData: SectorsData | null + onClose: () => void + t: ReturnType['t'] +}) { + const cs = sectorsData?.by_country[partner.country_code] + return ( +
+
+

{partner.country}

+ +
+

{FTA_LABELS[partner.fta_status]}

+
+ {[ + { label: t.trade.exports, val: partner.exports, cls: 'text-green-600 dark:text-green-400' }, + { label: t.trade.imports, val: partner.imports, cls: 'text-red-600 dark:text-red-400' }, + { label: t.trade.balance, val: partner.balance, cls: partner.balance >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }, + ].map(({ label, val, cls }) => ( +
+ {label} + {fmtB(val)} +
+ ))} +
+ {cs && cs.exports.length > 0 && ( + <> +
+

Exports by sector

+
+ {cs.exports.slice(0, 6).map(s => ( + x.code === s.sector_code)?.color ?? '#94a3b8'} + bilateralTotal={partner.exports} /> + ))} +
+ + )} + {cs && cs.imports.length > 0 && ( + <> +
+

Imports by sector

+
+ {cs.imports.slice(0, 5).map(s => ( + x.code === s.sector_code)?.color ?? '#94a3b8'} + bilateralTotal={partner.imports} /> + ))} +
+ + )} +
+ ) +} diff --git a/components/trade-partner-row.test.tsx b/components/trade-partner-row.test.tsx new file mode 100644 index 0000000..c7c7af9 --- /dev/null +++ b/components/trade-partner-row.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PartnerRow } from './trade-partner-row' +import type { TradePartner, SectorsData } from '@/lib/trade' + +const partner: TradePartner = { + country: 'Germany', + country_code: 'DE', + exports: 1000, + imports: 800, + balance: 200, + fta_status: 'EU_bilateral', +} + +const sectorsData: SectorsData = { + metadata: { source: 'test', year: 2024, note: '' }, + by_country: { + DE: { + exports: [{ sector: 'Pharma', sector_code: 'CHEM_PHARMA', share_pct: 25, value_CHF_millions: 250 }], + imports: [{ sector: 'Pharma', sector_code: 'CHEM_PHARMA', share_pct: 10, value_CHF_millions: 80 }], + }, + }, +} + +const baseProps = { + partner, + isHovered: false, + isSelected: false, + onHover: () => {}, + onSelect: () => {}, + maxForBar: 2000, + sectorFilter: null, + sectorsData: null, + sortMode: 'volume' as const, +} + +describe('PartnerRow', () => { + it('renders the country name and a positive balance in green', () => { + render() + expect(screen.getByText('Germany')).toBeInTheDocument() + const balance = screen.getByText('+200M') + expect(balance).toHaveClass('text-green-700') + }) + + it('renders a negative balance without a plus sign, in red', () => { + const negPartner: TradePartner = { ...partner, balance: -200, exports: 400, imports: 600 } + render() + const balance = screen.getByText('-200M') + expect(balance).toHaveClass('text-red-600') + }) + + it('renders raw export/import totals when no sector filter is active', () => { + render() + expect(screen.getByText('↑1.0B')).toBeInTheDocument() + expect(screen.getByText('↓800M')).toBeInTheDocument() + }) + + it('sizes the volume bar relative to maxForBar', () => { + const { container } = render() + const bar = container.querySelector('.h-full.rounded-full') as HTMLElement + // (1000 + 800) / 2000 = 90% + expect(bar.style.width).toBe('90%') + }) + + it('shows sector-specific exp/imp shares when a sector filter is active', () => { + render( + , + ) + expect(screen.getByText(/↑ 250M/)).toBeInTheDocument() + expect(screen.getByText('(25%)')).toBeInTheDocument() + expect(screen.getByText(/↓ 80M/)).toBeInTheDocument() + expect(screen.getByText('(10%)')).toBeInTheDocument() + }) + + it('shows the sector share badge only in "share" sort mode', () => { + const { rerender } = render( + , + ) + expect(screen.queryByText('25%')).not.toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText('25%')).toBeInTheDocument() + }) + + it('applies highlight classes when hovered or selected', () => { + const { rerender } = render() + expect(screen.getByRole('button')).toHaveClass('bg-muted/70') + + rerender() + expect(screen.getByRole('button')).toHaveClass('ring-1') + }) + + it('calls onSelect when clicked and onHover on mouse enter/leave', async () => { + const onSelect = vi.fn() + const onHover = vi.fn() + render() + + const row = screen.getByRole('button') + await userEvent.hover(row) + expect(onHover).toHaveBeenCalledWith('DE') + + await userEvent.unhover(row) + expect(onHover).toHaveBeenLastCalledWith(null) + + await userEvent.click(row) + expect(onSelect).toHaveBeenCalledWith('DE') + }) +}) diff --git a/components/trade-partner-row.tsx b/components/trade-partner-row.tsx new file mode 100644 index 0000000..7893796 --- /dev/null +++ b/components/trade-partner-row.tsx @@ -0,0 +1,73 @@ +import type { TradePartner, SectorsData } from '@/lib/trade' +import { sectorMetrics } from '@/lib/trade' +import { fmtB } from '@/lib/trade-format' + +export function PartnerRow({ partner, isHovered, isSelected, onHover, onSelect, maxForBar, + sectorFilter, sectorsData, sortMode }: { + partner: TradePartner + isHovered: boolean + isSelected: boolean + onHover: (code: string | null) => void + onSelect: (code: string | null) => void + maxForBar: number + sectorFilter: string | null + sectorsData: SectorsData | null + sortMode: 'share' | 'volume' +}) { + const sm = sectorFilter && sectorsData + ? sectorMetrics(partner, sectorsData.by_country, sectorFilter) + : null + + const displayBalance = sm ? sm.balance : partner.balance + const displayExp = sm ? sm.exp : partner.exports + const displayImp = sm ? sm.imp : partner.imports + const displayVolume = displayExp + displayImp + + const barWidthPct = Math.min(Math.round((displayVolume / maxForBar) * 100), 100) + const exportFrac = displayVolume > 0 ? (displayExp / displayVolume) * 100 : 50 + const badgeShare = sm ? Math.max(sm.expShare, sm.impShare) : null + + return ( + + ) +} diff --git a/components/trade-sector-bar.test.tsx b/components/trade-sector-bar.test.tsx new file mode 100644 index 0000000..805dc5c --- /dev/null +++ b/components/trade-sector-bar.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { SectorBar } from './trade-sector-bar' +import type { SectorEntry } from '@/lib/trade' + +const entry: SectorEntry = { + sector: 'Pharma', + sector_code: 'CHEM_PHARMA', + share_pct: 25, + value_CHF_millions: 100, +} + +describe('SectorBar', () => { + it('renders the sector label and share percentage', () => { + render() + expect(screen.getByText('Pharma')).toBeInTheDocument() + expect(screen.getByText('25%')).toBeInTheDocument() + }) + + it('formats the CHF value as share% of the bilateral total', () => { + render() + // 25% of 1000M = 250M + expect(screen.getByText('250M')).toBeInTheDocument() + }) + + it('renders the bar fill with the share percentage as width and the given color', () => { + const { container } = render() + const fill = container.querySelector('.h-full.rounded-full') as HTMLElement + expect(fill.style.width).toBe('25%') + expect(fill.style.backgroundColor).toBe('rgb(99, 102, 241)') + }) +}) diff --git a/components/trade-sector-bar.tsx b/components/trade-sector-bar.tsx new file mode 100644 index 0000000..f177775 --- /dev/null +++ b/components/trade-sector-bar.tsx @@ -0,0 +1,25 @@ +import type { SectorEntry } from '@/lib/trade' +import { fmtB } from '@/lib/trade-format' + +export function SectorBar({ entry, color, bilateralTotal }: { + entry: SectorEntry; color: string; bilateralTotal: number +}) { + const chf = (entry.share_pct / 100) * bilateralTotal + return ( +
+
+
+ + {entry.sector} +
+
+ {entry.share_pct}% + {fmtB(chf)} +
+
+
+
+
+
+ ) +} diff --git a/components/trade-sidebar.test.tsx b/components/trade-sidebar.test.tsx new file mode 100644 index 0000000..7f3db4f --- /dev/null +++ b/components/trade-sidebar.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, screen, within } from '@/test/test-utils' +import { TradeSidebar } from './trade-sidebar' +import type { TradeData, SectorsData, FtaStatus } from '@/lib/trade' + +const data: TradeData = { + metadata: { + source: 'BAZG', + reference_year: 2024, + currency: 'CHF', + unit: 'millions', + note: '', + downloaded: '2026-01-01', + total_exports: 3300, + total_imports: 2000, + trade_balance: 1300, + }, + partners: [ + { country: 'Germany', country_code: 'DE', exports: 1000, imports: 800, balance: 200, fta_status: 'EU_bilateral' }, + { country: 'United States', country_code: 'US', exports: 2000, imports: 500, balance: 1500, fta_status: 'in_force' }, + { country: 'France', country_code: 'FR', exports: 300, imports: 700, balance: -400, fta_status: 'EU_bilateral' }, + ], + sectors: { exports: [], imports: [] }, + timeseries: { annual: [], monthly_2025_2026: [] }, +} + +const sectorsData: SectorsData = { + metadata: { source: 'test', year: 2024, note: '' }, + by_country: { + DE: { + exports: [{ sector: 'Pharma', sector_code: 'CHEM_PHARMA', share_pct: 25, value_CHF_millions: 250 }], + imports: [], + }, + US: { + exports: [{ sector: 'Machines', sector_code: 'MACHINES_ELEC', share_pct: 10, value_CHF_millions: 200 }], + imports: [], + }, + }, +} + +const baseProps = { + isOpen: true, + onClose: () => {}, + data: null as TradeData | null, + sectorsData: null as SectorsData | null, + loadError: null as string | null, + hoveredCode: null as string | null, + onHover: () => {}, + selectedCode: null as string | null, + onSelect: () => {}, + ftaFilter: 'all' as FtaStatus | 'all', + onFtaFilter: () => {}, + sectorFilter: null as string | null, + onSectorFilter: () => {}, +} + +function partnerNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('button span.truncate.font-medium')).map(el => el.textContent) +} + +describe('TradeSidebar', () => { + it('shows a loading message while data has not arrived yet', () => { + renderWithProviders() + expect(screen.getByText('Loading…')).toBeInTheDocument() + }) + + it('shows an error message when loading failed', () => { + renderWithProviders() + expect(screen.getByText('boom')).toBeInTheDocument() + }) + + it('renders the export/import/balance summary totals', () => { + renderWithProviders() + expect(screen.getByText('3.3B')).toBeInTheDocument() + expect(screen.getByText('2.0B')).toBeInTheDocument() + expect(screen.getByText('+1.3B')).toBeInTheDocument() + }) + + it('renders all partners sorted by total trade volume', () => { + const { container } = renderWithProviders() + expect(screen.getByText('Top Partners (3)')).toBeInTheDocument() + expect(partnerNames(container)).toEqual(['United States', 'Germany', 'France']) + }) + + it('notifies the parent when an agreement filter is selected, and applies it', async () => { + const onFtaFilter = vi.fn() + const { container, rerender } = renderWithProviders( + , + ) + await userEvent.click(screen.getByText('EU')) + expect(onFtaFilter).toHaveBeenCalledWith('EU_bilateral') + + rerender() + expect(screen.getByText('Top Partners (2)')).toBeInTheDocument() + expect(partnerNames(container)).toEqual(['Germany', 'France']) + }) + + it('notifies the parent when a sector filter is selected, and applies it', async () => { + const onSectorFilter = vi.fn() + const { container, rerender } = renderWithProviders( + , + ) + await userEvent.click(screen.getByText('Pharma')) + expect(onSectorFilter).toHaveBeenCalledWith('CHEM_PHARMA') + + rerender( + , + ) + expect(screen.getByText('Pharma (1)')).toBeInTheDocument() + expect(partnerNames(container)).toEqual(['Germany']) + expect(screen.getByText('CHF')).toBeInTheDocument() + expect(screen.getByText('%')).toBeInTheDocument() + }) + + it('filters the partner list by search text', async () => { + const { container } = renderWithProviders() + + await userEvent.type(screen.getByRole('searchbox'), 'united') + expect(screen.getByText('Top Partners (1)')).toBeInTheDocument() + expect(partnerNames(container)).toEqual(['United States']) + + await userEvent.clear(screen.getByRole('searchbox')) + await userEvent.type(screen.getByRole('searchbox'), 'zzz') + expect(screen.getByText('No match for "zzz"')).toBeInTheDocument() + }) + + it('shows a partner card for the selected country and lets the user close it', async () => { + const onSelect = vi.fn() + renderWithProviders() + + expect(screen.getByText('EU Bilateral')).toBeInTheDocument() + + const cardHeader = screen.getByText('Germany', { selector: 'p' }).parentElement! + await userEvent.click(within(cardHeader).getByRole('button')) + expect(onSelect).toHaveBeenCalledWith('DE') + }) + + it('calls onClose when the mobile close button is clicked', async () => { + const onClose = vi.fn() + renderWithProviders() + await userEvent.click(screen.getAllByRole('button', { name: '' })[0]) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/components/trade-sidebar.tsx b/components/trade-sidebar.tsx index 50c0843..288c875 100644 --- a/components/trade-sidebar.tsx +++ b/components/trade-sidebar.tsx @@ -1,83 +1,16 @@ 'use client' import { useState, useMemo } from 'react' +import { X } from 'lucide-react' import { useLanguage } from '@/contexts/language' -import type { TradeData, TradePartner, FtaStatus, SectorsData, SectorEntry, AnnualTotal } from '@/lib/trade' -import { FTA_LABELS, sectorMetrics } from '@/lib/trade' - -function fmtB(millions: number): string { - if (Math.abs(millions) >= 1000) return `${(millions / 1000).toFixed(1)}B` - return `${millions.toFixed(0)}M` -} - -function yoyPct(annual: AnnualTotal[], field: 'exports' | 'imports' | 'balance'): number | null { - const final = annual.filter(a => !a.preliminary) - if (final.length < 2) return null - const prev = final[final.length - 2][field] - const curr = final[final.length - 1][field] - if (prev === 0) return null - return ((curr - prev) / Math.abs(prev)) * 100 -} - -// ── Sparkline ───────────────────────────────────────────────────────────────── - -function TradeSparkline({ annual }: { annual: AnnualTotal[] }) { - const data = annual.filter(a => !a.preliminary) - if (data.length < 2) return null - - const W = 240 - const H = 44 - const PAD = { l: 0, r: 0, t: 4, b: 14 } - const innerW = W - PAD.l - PAD.r - const innerH = H - PAD.t - PAD.b - - const allVals = data.flatMap(a => [a.exports, a.imports]) - const minV = Math.min(...allVals) * 0.92 - const maxV = Math.max(...allVals) * 1.02 - - const xOf = (i: number) => PAD.l + (i / (data.length - 1)) * innerW - const yOf = (v: number) => PAD.t + (1 - (v - minV) / (maxV - minV)) * innerH - - const expLine = data.map((a, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(a.exports).toFixed(1)}`).join(' ') - const impLine = data.map((a, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(a.imports).toFixed(1)}`).join(' ') - - // Balance area (shaded between exports and imports at each point) - const balAreaTop = data.map((a, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(a.exports).toFixed(1)}`).join(' ') - const balAreaBot = data.map((a, i) => `L${xOf(i).toFixed(1)},${yOf(a.imports).toFixed(1)}`).reverse().join(' ') - const balArea = `${balAreaTop} ${balAreaBot} Z` - - const firstYear = data[0].year - const lastYear = data[data.length - 1].year - - return ( - - {/* Balance fill between the two lines */} - - {/* Import line */} - - {/* Export line */} - - {/* Year labels */} - {firstYear} - {lastYear} - - ) -} - -// ── Sector definitions ───────────────────────────────────────────────────────── - -export const SECTORS = [ - { code: 'CHEM_PHARMA', label: 'Pharma', color: '#6366f1' }, - { code: 'MACHINES_ELEC', label: 'Machines', color: '#0ea5e9' }, - { code: 'WATCHES', label: 'Watches', color: '#d97706' }, - { code: 'PRECISION', label: 'Medtech', color: '#10b981' }, - { code: 'METALS', label: 'Metals', color: '#78716c' }, - { code: 'VEHICLES', label: 'Vehicles', color: '#3b82f6' }, - { code: 'TEXTILES', label: 'Textiles', color: '#ec4899' }, - { code: 'AGRI', label: 'Agri', color: '#65a30d' }, - { code: 'ENERGY', label: 'Energy', color: '#f97316' }, - { code: 'OTHER', label: 'Other', color: '#94a3b8' }, -] +import type { TradeData, FtaStatus, SectorsData } from '@/lib/trade' +import { sectorMetrics, SECTORS } from '@/lib/trade' +import { fmtB, yoyPct } from '@/lib/trade-format' +import { Button } from './ui/button' +import { SelectButton } from './ui/select-button' +import { TradeSparkline } from './trade-sparkline' +import { PartnerCard } from './trade-partner-card' +import { PartnerRow } from './trade-partner-row' const FTA_OPTIONS: Array<{ value: FtaStatus | 'all'; label: string }> = [ { value: 'all', label: 'All' }, @@ -89,163 +22,16 @@ const FTA_OPTIONS: Array<{ value: FtaStatus | 'all'; label: string }> = [ { value: 'none', label: 'No FTA' }, ] -// ── SectorBar ────────────────────────────────────────────────────────────────── - -function SectorBar({ entry, color, bilateralTotal }: { - entry: SectorEntry; color: string; bilateralTotal: number -}) { - const chf = (entry.share_pct / 100) * bilateralTotal +function YoyBadge({ pct }: { pct: number | null }) { + if (pct === null) return null + const up = pct >= 0 return ( -
-
-
- - {entry.sector} -
-
- {entry.share_pct}% - {fmtB(chf)} -
-
-
-
-
-
+ + {up ? '▲' : '▼'}{Math.abs(pct).toFixed(1)}% + ) } -// ── PartnerCard ──────────────────────────────────────────────────────────────── - -function PartnerCard({ partner, sectorsData, onClose, t }: { - partner: TradePartner - sectorsData: SectorsData | null - onClose: () => void - t: ReturnType['t'] -}) { - const cs = sectorsData?.by_country[partner.country_code] - return ( -
-
-

{partner.country}

- -
-

{FTA_LABELS[partner.fta_status]}

-
- {[ - { label: t.trade.exports, val: partner.exports, cls: 'text-green-600 dark:text-green-400' }, - { label: t.trade.imports, val: partner.imports, cls: 'text-red-600 dark:text-red-400' }, - { label: t.trade.balance, val: partner.balance, cls: partner.balance >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }, - ].map(({ label, val, cls }) => ( -
- {label} - {fmtB(val)} -
- ))} -
- {cs && cs.exports.length > 0 && ( - <> -
-

Exports by sector

-
- {cs.exports.slice(0, 6).map(s => ( - x.code === s.sector_code)?.color ?? '#94a3b8'} - bilateralTotal={partner.exports} /> - ))} -
- - )} - {cs && cs.imports.length > 0 && ( - <> -
-

Imports by sector

-
- {cs.imports.slice(0, 5).map(s => ( - x.code === s.sector_code)?.color ?? '#94a3b8'} - bilateralTotal={partner.imports} /> - ))} -
- - )} -
- ) -} - -// ── PartnerRow ───────────────────────────────────────────────────────────────── - -function PartnerRow({ partner, isHovered, isSelected, onHover, onSelect, maxForBar, - sectorFilter, sectorsData, sortMode }: { - partner: TradePartner - isHovered: boolean - isSelected: boolean - onHover: (code: string | null) => void - onSelect: (code: string | null) => void - maxForBar: number - sectorFilter: string | null - sectorsData: SectorsData | null - sortMode: 'share' | 'volume' -}) { - const sm = sectorFilter && sectorsData - ? sectorMetrics(partner, sectorsData.by_country, sectorFilter) - : null - - const displayBalance = sm ? sm.balance : partner.balance - const displayExp = sm ? sm.exp : partner.exports - const displayImp = sm ? sm.imp : partner.imports - const displayVolume = displayExp + displayImp - - const barWidthPct = Math.min(Math.round((displayVolume / maxForBar) * 100), 100) - const exportFrac = displayVolume > 0 ? (displayExp / displayVolume) * 100 : 50 - const badgeShare = sm ? Math.max(sm.expShare, sm.impShare) : null - - return ( - - ) -} - -// ── Main component ───────────────────────────────────────────────────────────── - interface TradeSidebarProps { isOpen: boolean onClose: () => void @@ -307,16 +93,6 @@ export function TradeSidebar({ const selectedPartner = data?.partners.find(p => p.country_code === selectedCode) ?? null - function YoyBadge({ pct }: { pct: number | null }) { - if (pct === null) return null - const up = pct >= 0 - return ( - - {up ? '▲' : '▼'}{Math.abs(pct).toFixed(1)}% - - ) - } - return (