spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';3import { useCallback, useEffect, useId, useRef, useState } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';67/**8 * Time machine control: a year slider over an explicit list of available years (gaps allowed), with9 * play / pause (steps every `interval` ms, loops back to the first year), first / last jumps, keyboard10 * arrows on the range input, a large tap-friendly thumb and a compact year readout. Fully controlled:11 * the parent owns `year` (usually mirrored in the URL via `useUrlState`).12 */13export function YearSlider({14 years,15 year,16 onChange,17 playable = true,18 interval = 700,19 compact = false,20 className,21 label,22 autoplay = false,23 onPlayingChange,24 ticks = true,25}: {26 years: number[];27 year: number;28 onChange: (year: number) => void;29 playable?: boolean;30 interval?: number;31 compact?: boolean;32 className?: string;33 label?: string;34 autoplay?: boolean;35 onPlayingChange?: (playing: boolean) => void;36 ticks?: boolean;37}) {38 const id = useId();39 const [playing, setPlaying] = useState(autoplay);40 const idx = Math.max(0, years.indexOf(year));41 const timer = useRef<ReturnType<typeof setInterval> | null>(null);42 const idxRef = useRef(idx);43 idxRef.current = idx;4445 const stop = useCallback(() => {46 setPlaying(false);47 }, []);4849 useEffect(() => {50 onPlayingChange?.(playing);51 if (!playing) {52 if (timer.current) clearInterval(timer.current);53 timer.current = null;54 return;55 }56 if (years.length < 2) {57 setPlaying(false);58 return;59 }60 timer.current = setInterval(() => {61 const next = idxRef.current + 1;62 if (next >= years.length) {63 setPlaying(false);64 return;65 }66 onChange(years[next]!);67 }, interval);68 return () => {69 if (timer.current) clearInterval(timer.current);70 };71 // eslint-disable-next-line react-hooks/exhaustive-deps72 }, [playing, interval, years.length]);7374 const first = years[0] ?? year;75 const last = years[years.length - 1] ?? year;76 const start = () => {77 if (idxRef.current >= years.length - 1) onChange(first);78 setPlaying(true);79 };80 const tickYears = ticks ? pickTicks(years, compact ? 3 : 6) : [];8182 return (83 <div className={cn('min-w-0', className)} role="group" aria-label={label ?? t('control.year')}>84 <div className="flex items-center gap-2">85 {playable ? (86 <div className="flex shrink-0 items-center">87 <button type="button" onClick={() => { stop(); onChange(first); }} disabled={idx === 0} className="tap hidden place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30 sm:grid md:min-h-[36px] md:min-w-[36px]" aria-label={t('control.first')}>88 <SkipBack size={15} aria-hidden />89 </button>90 <button type="button" onClick={() => (playing ? stop() : start())} className={cn('tap grid place-items-center rounded-sm md:min-h-[36px] md:min-w-[36px]', playing ? 'bg-ink text-paper' : 'bg-accent text-accent-ink hover:opacity-90')} aria-label={playing ? t('control.pause') : t('control.play')} aria-pressed={playing}>91 {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden />}92 </button>93 <button type="button" onClick={() => { stop(); onChange(last); }} disabled={idx >= years.length - 1} className="tap hidden place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30 sm:grid md:min-h-[36px] md:min-w-[36px]" aria-label={t('control.last')}>94 <SkipForward size={15} aria-hidden />95 </button>96 </div>97 ) : null}98 <div className="relative min-w-0 flex-1">99 <input100 id={`${id}-range`}101 type="range"102 min={0}103 max={Math.max(0, years.length - 1)}104 step={1}105 value={idx}106 onChange={(e) => {107 stop();108 onChange(years[Number(e.target.value)] ?? year);109 }}110 aria-label={label ?? t('control.year')}111 aria-valuetext={String(year)}112 aria-valuemin={first}113 aria-valuemax={last}114 aria-valuenow={year}115 className="ca-range h-11 w-full min-w-0 md:h-9"116 style={{ touchAction: 'pan-y' }}117 disabled={years.length < 2}118 />119 {tickYears.length ? (120 <div aria-hidden className="tnum pointer-events-none relative -mt-1 h-3 text-2xs text-ink-3">121 {tickYears.map((y) => {122 const pos = years.length > 1 ? (years.indexOf(y) / (years.length - 1)) * 100 : 0;123 return (124 <span key={y} className="absolute -translate-x-1/2" style={{ left: `calc(${pos}% )` }}>125 {y}126 </span>127 );128 })}129 </div>130 ) : null}131 </div>132 <output htmlFor={`${id}-range`} className={cn('tnum shrink-0 text-right font-semibold text-ink', compact ? 'w-12 text-base' : 'w-16 text-xl md:text-2xl')}>133 {year}134 </output>135 </div>136 </div>137 );138}139140/** Evenly spaced tick years including first and last, snapping to round decades when the span allows. */141export function pickTicks(years: number[], n: number): number[] {142 if (years.length < 2) return years;143 const first = years[0]!;144 const last = years[years.length - 1]!;145 const span = last - first;146 const step = span / Math.max(1, n - 1);147 const out = new Set<number>([first, last]);148 for (let i = 1; i < n - 1; i++) {149 const target = first + i * step;150 const decade = Math.round(target / 10) * 10;151 const candidate = years.includes(decade) ? decade : years.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a), first);152 if (candidate - first > step / 2 && last - candidate > step / 2) out.add(candidate);153 }154 return Array.from(out).sort((a, b) => a - b);155}156