SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.9 KB · 156 lines tsx
Raw Blame History
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