From 2144a01d0c09b73402fafc0e4b8fefb471a8633f Mon Sep 17 00:00:00 2001 From: Mike Spahr Date: Sat, 13 Jun 2026 07:24:15 +0200 Subject: [PATCH 01/19] update todo --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 25075d7..50d19fa 100644 --- a/TODO.md +++ b/TODO.md @@ -9,4 +9,5 @@ - 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 \ No newline at end of file From 8277d887a2ed94535255354a39d25fae8f211525 Mon Sep 17 00:00:00 2001 From: Mike Spahr Date: Sat, 13 Jun 2026 10:07:21 +0200 Subject: [PATCH 02/19] massive refacto --- app/layout.tsx | 2 +- components/app-header.tsx | 9 +- components/app-sidebar.tsx | 98 +--------- components/data-layout-elements.tsx | 53 ++++++ components/data-layout.tsx | 50 +----- components/demographics-map.tsx | 16 +- components/demographics-sidebar.tsx | 8 +- components/language-switcher.tsx | 13 +- components/map-shell.tsx | 7 +- components/map-tooltip.tsx | 19 ++ components/result-block.tsx | 81 +++++++++ components/swiss-map.tsx | 79 +------- components/trade-map.tsx | 7 +- components/trade-partner-card.tsx | 65 +++++++ components/trade-partner-row.tsx | 73 ++++++++ components/trade-sector-bar.tsx | 25 +++ components/trade-sidebar.tsx | 269 +++------------------------- components/trade-sparkline.tsx | 44 +++++ components/ui/badge.tsx | 49 +++++ components/ui/select-button.tsx | 22 +++ components/ui/separator.tsx | 28 +++ components/ui/toggle-group.tsx | 89 +++++++++ components/ui/toggle.tsx | 47 +++++ lib/demographics.ts | 6 +- lib/fetch.ts | 5 + lib/map-style.ts | 33 +++- lib/map-utils.ts | 45 +++++ lib/trade-format.ts | 15 ++ lib/trade.ts | 23 ++- lib/votation.ts | 46 ++--- 30 files changed, 796 insertions(+), 530 deletions(-) create mode 100644 components/data-layout-elements.tsx create mode 100644 components/map-tooltip.tsx create mode 100644 components/result-block.tsx create mode 100644 components/trade-partner-card.tsx create mode 100644 components/trade-partner-row.tsx create mode 100644 components/trade-sector-bar.tsx create mode 100644 components/trade-sparkline.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/select-button.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 lib/fetch.ts create mode 100644 lib/map-utils.ts create mode 100644 lib/trade-format.ts diff --git a/app/layout.tsx b/app/layout.tsx index 5e0e586..23e1e6d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import { Geist, Geist_Mono, Inter } from "next/font/google" +import { Geist_Mono, Inter } from "next/font/google" import "./globals.css" import { ThemeProvider } from "@/components/theme-provider" 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.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.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..3221328 100644 --- a/components/data-layout.tsx +++ b/components/data-layout.tsx @@ -2,55 +2,7 @@ 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) diff --git a/components/demographics-map.tsx b/components/demographics-map.tsx index 73cafb6..5ed7b91 100644 --- a/components/demographics-map.tsx +++ b/components/demographics-map.tsx @@ -8,17 +8,14 @@ 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, -] - // Colors for the 3-class typology const TYPOLOGY_COLORS: Record = { 1: '#1d4ed8', 2: '#60a5fa', 3: '#bfdbfe' } @@ -96,7 +93,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp const [tooltip, setTooltip] = useState(null) useEffect(() => { - loadStrippedStyle().then(setBaseStyle) + loadMapStyle(true).then(setBaseStyle) }, []) useEffect(() => { @@ -187,10 +184,7 @@ export default function DemographicsMap({ communes, topic }: DemographicsMapProp {/* Tooltip */} {tooltip && ( -
+

{tooltip.name}

{tooltip.value > NO_DATA ? (

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

)} -
+ )} {/* Legend */} 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.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..ca12a5f 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 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.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..0f341f8 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(() => { @@ -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..86e6ada 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] @@ -65,7 +64,7 @@ export default function TradeMap({ 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 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.tsx b/components/trade-partner-row.tsx new file mode 100644 index 0000000..deb1bb8 --- /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.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.tsx b/components/trade-sidebar.tsx index 50c0843..b2ebc55 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, TradePartner, 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,6 @@ 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 - return ( -
-
-
- - {entry.sector} -
-
- {entry.share_pct}% - {fmtB(chf)} -
-
-
-
-
-
- ) -} - -// ── 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 @@ -325,7 +101,9 @@ export function TradeSidebar({ `}>

{t.trade.title}

- +
{/* Sticky top */} @@ -383,19 +161,19 @@ export function TradeSidebar({

Sector

- + {SECTORS.map(s => ( @@ -408,12 +186,15 @@ export function TradeSidebar({

Agreement

{FTA_OPTIONS.map(opt => ( - + ))}
diff --git a/components/trade-sparkline.tsx b/components/trade-sparkline.tsx new file mode 100644 index 0000000..8aaf5af --- /dev/null +++ b/components/trade-sparkline.tsx @@ -0,0 +1,44 @@ +import type { AnnualTotal } from '@/lib/trade' + +export 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} + + ) +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fe670e9 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/select-button.tsx b/components/ui/select-button.tsx new file mode 100644 index 0000000..0841f7a --- /dev/null +++ b/components/ui/select-button.tsx @@ -0,0 +1,22 @@ +import type { ButtonHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +interface SelectButtonProps extends ButtonHTMLAttributes { + active: boolean + /** Classes applied only when inactive (background/hover treatment) */ + inactiveClassName?: string +} + +/** Pill-style toggle button for date/language/filter selectors. */ +export function SelectButton({ active, inactiveClassName, className, ...props }: SelectButtonProps) { + return ( +