Server-safe URL/indicator-option helpers; frames values rounded to 6 significant digits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 changed files +61 −45
modified
apps/web/src/components/controls/indicator-select.tsx
+2 −16
@@ -4,23 +4,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react'; | ||
| 4 | 4 | import { t } from '@/i18n'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | 6 | import { topicById } from '@/lib/topics'; |
| 7 | −import type { IndicatorSummary } from '@/lib/types'; | |
| 8 | 7 | |
| 9 | −export interface IndicatorOption { | |
| 10 | − slug: string; | |
| 11 | − name: string; | |
| 12 | − short_name?: string | null; | |
| 13 | − topic?: string | null; | |
| 14 | − unit?: string | null; | |
| 15 | − featured?: boolean | null; | |
| 16 | − first_year?: number | null; | |
| 17 | − last_year?: number | null; | |
| 18 | − n_countries?: number | null; | |
| 19 | −} | |
| 20 | − | |
| 21 | −export function toIndicatorOption(i: IndicatorSummary): IndicatorOption { | |
| 22 | − return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries }; | |
| 23 | −} | |
| 8 | +import { toIndicatorOption, type IndicatorOption } from '@/lib/indicator-options'; | |
| 9 | +export { toIndicatorOption, type IndicatorOption }; | |
| 24 | 10 | |
| 25 | 11 | /** |
| 26 | 12 | * Indicator picker for the analytical views: a button showing the current indicator, opening a searchable |
added
apps/web/src/lib/indicator-options.ts
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +/** Server-safe indicator option shape for pickers (no 'use client'); re-exported by components/controls/indicator-select.tsx. */ | |
| 2 | +import type { IndicatorSummary } from './types'; | |
| 3 | + | |
| 4 | +export interface IndicatorOption { | |
| 5 | + slug: string; | |
| 6 | + name: string; | |
| 7 | + short_name?: string | null; | |
| 8 | + topic?: string | null; | |
| 9 | + unit?: string | null; | |
| 10 | + featured?: boolean | null; | |
| 11 | + first_year?: number | null; | |
| 12 | + last_year?: number | null; | |
| 13 | + n_countries?: number | null; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function toIndicatorOption(i: IndicatorSummary): IndicatorOption { | |
| 17 | + return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries }; | |
| 18 | +} | |
added
apps/web/src/lib/url-params.ts
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +/** | |
| 2 | + * Server-safe URL parameter helpers (no 'use client' directive) shared by server components (searchParams | |
| 3 | + * parsing) and the client `useUrlState` hook in ./url-state.ts, which re-exports them. | |
| 4 | + */ | |
| 5 | +export type UrlPatch = Record<string, string | number | boolean | null | undefined>; | |
| 6 | + | |
| 7 | +export function applyPatch(current: URLSearchParams, patch: UrlPatch): URLSearchParams { | |
| 8 | + const p = new URLSearchParams(current.toString()); | |
| 9 | + for (const [k, v] of Object.entries(patch)) { | |
| 10 | + if (v === undefined || v === null || v === '' || v === false) p.delete(k); | |
| 11 | + else p.set(k, v === true ? '1' : String(v)); | |
| 12 | + } | |
| 13 | + return p; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Parse "a,b,c" → ["a","b","c"] limited to slug-safe tokens. */ | |
| 17 | +export function parseList(v: string | null | undefined, max = 8): string[] { | |
| 18 | + if (!v) return []; | |
| 19 | + const out: string[] = []; | |
| 20 | + for (const part of v.split(',')) { | |
| 21 | + const s = part.trim().toLowerCase(); | |
| 22 | + if (s && /^[a-z0-9][a-z0-9-]*$/.test(s) && !out.includes(s)) out.push(s); | |
| 23 | + if (out.length >= max) break; | |
| 24 | + } | |
| 25 | + return out; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export function parseYear(v: string | null | undefined): number | null { | |
| 29 | + if (!v) return null; | |
| 30 | + const n = Number(v); | |
| 31 | + return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** First string value of a Next `searchParams` entry. */ | |
| 35 | +export function firstParam(v: string | string[] | undefined): string | null { | |
| 36 | + return Array.isArray(v) ? (v[0] ?? null) : (v ?? null); | |
| 37 | +} | |
modified
apps/web/src/lib/url-state.ts
+2 −28
@@ -10,16 +10,8 @@ import { useCallback, useMemo, useRef } from 'react'; | ||
| 10 | 10 | * const { get, set, url } = useUrlState(); |
| 11 | 11 | * set({ year: 1990, indicator: 'gdp-per-capita' }) // null/undefined/'' remove the key |
| 12 | 12 | */ |
| 13 | −export type UrlPatch = Record<string, string | number | boolean | null | undefined>; | |
| 14 | − | |
| 15 | −export function applyPatch(current: URLSearchParams, patch: UrlPatch): URLSearchParams { | |
| 16 | − const p = new URLSearchParams(current.toString()); | |
| 17 | − for (const [k, v] of Object.entries(patch)) { | |
| 18 | − if (v === undefined || v === null || v === '' || v === false) p.delete(k); | |
| 19 | − else p.set(k, v === true ? '1' : String(v)); | |
| 20 | − } | |
| 21 | − return p; | |
| 22 | −} | |
| 13 | +import { applyPatch, type UrlPatch } from './url-params'; | |
| 14 | +export { applyPatch, parseList, parseYear, type UrlPatch } from './url-params'; | |
| 23 | 15 | |
| 24 | 16 | export function useUrlState(defaults: Record<string, string> = {}) { |
| 25 | 17 | const router = useRouter(); |
@@ -64,21 +56,3 @@ export function useUrlState(defaults: Record<string, string> = {}) { | ||
| 64 | 56 | const url = useMemo(() => `${pathname}${params.toString() ? `?${params.toString()}` : ''}`, [pathname, params]); |
| 65 | 57 | return { get, getNum, set, params, url } as const; |
| 66 | 58 | } |
| 67 | − | |
| 68 | −/** Parse "a,b,c" → ["a","b","c"] limited to slug-safe tokens. */ | |
| 69 | −export function parseList(v: string | null | undefined, max = 8): string[] { | |
| 70 | − if (!v) return []; | |
| 71 | − const out: string[] = []; | |
| 72 | − for (const part of v.split(',')) { | |
| 73 | − const s = part.trim().toLowerCase(); | |
| 74 | − if (s && /^[a-z0-9][a-z0-9-]*$/.test(s) && !out.includes(s)) out.push(s); | |
| 75 | − if (out.length >= max) break; | |
| 76 | − } | |
| 77 | − return out; | |
| 78 | −} | |
| 79 | − | |
| 80 | −export function parseYear(v: string | null | undefined): number | null { | |
| 81 | − if (!v) return null; | |
| 82 | − const n = Number(v); | |
| 83 | − return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null; | |
| 84 | −} | |
modified
src/countryatlas/api/routers/indicators.py
+2 −1
@@ -480,7 +480,8 @@ def get_indicator_frames(slug: str, from_: int | None = Query(None, alias="from" | ||
| 480 | 480 | rows = snap.query_rows(f"SELECT o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id WHERE {' AND '.join(where)}", params) |
| 481 | 481 | by_year: dict[int, dict[str, float]] = {} |
| 482 | 482 | for cid, yr, v in rows: |
| 483 | − by_year.setdefault(int(yr), {})[cid] = float(v) | |
| 483 | + # 6 significant digits: halves the payload of a 66-frame × 215-country response with no visible loss | |
| 484 | + by_year.setdefault(int(yr), {})[cid] = float(f"{float(v):.6g}") | |
| 484 | 485 | years = sorted(y for y, d in by_year.items() if len(d) >= 20) |
| 485 | 486 | if step > 1: |
| 486 | 487 | years = [y for y in years if (y - years[0]) % step == 0] if years else [] |
| 487 | 488 | |