refactor(transport): group by route with two direction columns
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Restructures the tram widget: instead of one card per stop (showing all routes at that stop), now one row per route (23, 27, 39) with two columns — → Лента (в центр) and → Дыбенко (от центра). Each cell shows the next arrival prominently plus the following 1-2 pickups inline.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { ArrowRight, ArrowLeft, Accessibility, Train } from 'lucide-react'
|
import { ArrowRight, Train } from 'lucide-react'
|
||||||
|
|
||||||
interface Arrival {
|
interface Arrival {
|
||||||
route: string
|
route: string
|
||||||
@@ -12,171 +12,88 @@ interface Arrival {
|
|||||||
|
|
||||||
interface Direction {
|
interface Direction {
|
||||||
stopId: string
|
stopId: string
|
||||||
title: string
|
short: string
|
||||||
subtitle: string
|
sub: string
|
||||||
icon: 'right' | 'left'
|
|
||||||
accent: string
|
|
||||||
glow: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECTIONS: Direction[] = [
|
const DIRECTIONS: Direction[] = [
|
||||||
{
|
{ stopId: '16226', short: 'Лента', sub: 'в центр' },
|
||||||
stopId: '16226',
|
{ stopId: '16354', short: 'Дыбенко', sub: 'от центра' },
|
||||||
title: 'В центр',
|
|
||||||
subtitle: 'к м. Новочеркасская',
|
|
||||||
icon: 'right',
|
|
||||||
accent: 'linear-gradient(135deg, rgba(16,185,129,0.14), rgba(6,182,212,0.06))',
|
|
||||||
glow: 'rgba(16,185,129,0.35)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stopId: '16354',
|
|
||||||
title: 'Из центра',
|
|
||||||
subtitle: 'к пр. Большевиков',
|
|
||||||
icon: 'left',
|
|
||||||
accent: 'linear-gradient(135deg, rgba(236,72,153,0.14), rgba(245,158,11,0.06))',
|
|
||||||
glow: 'rgba(236,72,153,0.35)',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALLOWED_ROUTES = new Set(['23', '27', '39'])
|
const ROUTES: { num: string; color: string; bg: string }[] = [
|
||||||
|
{ num: '23', color: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
|
||||||
|
{ num: '27', color: '#fbbf24', bg: 'linear-gradient(135deg, #f59e0b, #d97706)' },
|
||||||
|
{ num: '39', color: '#c084fc', bg: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
|
||||||
|
]
|
||||||
|
|
||||||
const ROUTE_STYLE: Record<string, { color: string; bg: string }> = {
|
function formatMinutes(m: number): string {
|
||||||
'23': { color: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' },
|
if (m <= 0) return 'сейчас'
|
||||||
'27': { color: '#fbbf24', bg: 'linear-gradient(135deg, #f59e0b, #d97706)' },
|
return `${m} мин`
|
||||||
'39': { color: '#c084fc', bg: 'linear-gradient(135deg, #a855f7, #7c3aed)' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMinutes(m: number): { big: string; unit: string } {
|
function Cell({ arrivals, color }: { arrivals: Arrival[]; color: string }) {
|
||||||
if (m <= 0) return { big: 'сейчас', unit: '' }
|
|
||||||
if (m === 1) return { big: '1', unit: 'мин' }
|
|
||||||
return { big: String(m), unit: 'мин' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function DirectionCard({ dir, arrivals, loading }: { dir: Direction; arrivals: Arrival[]; loading: boolean }) {
|
|
||||||
const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3)
|
const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3)
|
||||||
const ArrowIcon = dir.icon === 'right' ? ArrowRight : ArrowLeft
|
if (sorted.length === 0) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: dir.accent,
|
|
||||||
backdropFilter: 'blur(20px)',
|
|
||||||
WebkitBackdropFilter: 'blur(20px)' as any,
|
|
||||||
border: '1px solid rgba(255,255,255,0.06)',
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: '16px 18px',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 12,
|
|
||||||
position: 'relative', overflow: 'hidden',
|
|
||||||
minHeight: 180,
|
|
||||||
}}>
|
|
||||||
{/* Glow */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: -40, right: -40,
|
|
||||||
width: 140, height: 140, borderRadius: '50%',
|
|
||||||
background: `radial-gradient(circle, ${dir.glow} 0%, transparent 70%)`,
|
|
||||||
opacity: 0.5, pointerEvents: 'none',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, position: 'relative', zIndex: 1 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 34, height: 34, borderRadius: 11,
|
|
||||||
background: 'rgba(255,255,255,0.08)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: 'var(--text-primary)', flexShrink: 0,
|
padding: '10px 14px', borderRadius: 12,
|
||||||
}}>
|
background: 'rgba(255,255,255,0.02)',
|
||||||
<ArrowIcon size={16} />
|
border: '1px dashed rgba(255,255,255,0.06)',
|
||||||
</div>
|
color: 'var(--text-tertiary)', fontSize: 13, fontWeight: 500,
|
||||||
<div style={{ minWidth: 0 }}>
|
minHeight: 52,
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.2px' }}>
|
}}>—</div>
|
||||||
{dir.title}
|
)
|
||||||
</div>
|
}
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
const [first, ...rest] = sorted
|
||||||
{dir.subtitle}
|
const imminent = first.minutes <= 2
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrivals */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1, position: 'relative', zIndex: 1 }}>
|
|
||||||
{loading && arrivals.length === 0 ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-tertiary)', fontSize: 12 }}>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
) : sorted.length === 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-tertiary)', gap: 6 }}>
|
|
||||||
<Train size={22} style={{ opacity: 0.4 }} />
|
|
||||||
<div style={{ fontSize: 12 }}>Пока нет данных</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{sorted.map((arr, idx) => {
|
|
||||||
const style = ROUTE_STYLE[arr.route] || { color: '#9ca3af', bg: 'linear-gradient(135deg, #6b7280, #4b5563)' }
|
|
||||||
const time = formatMinutes(arr.minutes)
|
|
||||||
const isImminent = arr.minutes <= 2
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
|
||||||
key={`${arr.route}-${arr.park}-${idx}`}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
transition={{ duration: 0.25, delay: idx * 0.04 }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 11,
|
|
||||||
padding: '8px 10px', borderRadius: 13,
|
|
||||||
background: isImminent ? `${style.color}15` : 'rgba(255,255,255,0.025)',
|
|
||||||
border: `1px solid ${isImminent ? style.color + '35' : 'rgba(255,255,255,0.05)'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Route badge */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 34, height: 34, borderRadius: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
background: style.bg,
|
padding: '8px 14px', borderRadius: 12,
|
||||||
boxShadow: `0 4px 12px ${style.color}30`,
|
background: imminent ? `${color}15` : 'rgba(255,255,255,0.03)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
border: `1px solid ${imminent ? color + '35' : 'rgba(255,255,255,0.06)'}`,
|
||||||
color: 'white', fontWeight: 800, fontSize: 14,
|
minHeight: 52,
|
||||||
flexShrink: 0, letterSpacing: '-0.5px',
|
transition: 'all 0.25s ease',
|
||||||
}}>
|
}}>
|
||||||
{arr.route}
|
{/* Primary time */}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', fontWeight: 500, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
|
||||||
Трамвай
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1 }}>
|
|
||||||
№{arr.park}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, flexShrink: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: time.big === 'сейчас' ? 15 : 22,
|
fontSize: first.minutes <= 0 ? 16 : 24,
|
||||||
fontWeight: 800,
|
fontWeight: 800, letterSpacing: '-1px', lineHeight: 1,
|
||||||
color: isImminent ? style.color : 'var(--text-primary)',
|
color: imminent ? color : 'var(--text-primary)',
|
||||||
lineHeight: 1, letterSpacing: '-1px',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
}}>
|
}}>
|
||||||
{time.big}
|
{first.minutes <= 0 ? 'сейчас' : first.minutes}
|
||||||
</div>
|
|
||||||
{time.unit && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
|
||||||
{time.unit}
|
|
||||||
</div>
|
</div>
|
||||||
|
{first.minutes > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 500 }}>мин</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wheelchair */}
|
{/* Divider */}
|
||||||
{arr.wheelchair && (
|
{rest.length > 0 && (
|
||||||
<Accessibility size={12} color="var(--text-tertiary)" style={{ flexShrink: 0 }} />
|
<div style={{ width: 1, alignSelf: 'stretch', background: 'rgba(255,255,255,0.06)' }} />
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Next arrivals */}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 600,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
}}>
|
||||||
|
затем
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{rest.map(r => formatMinutes(r.minutes)).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -199,9 +116,7 @@ export default function TransportWidget() {
|
|||||||
)
|
)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const map: Record<string, Arrival[]> = {}
|
const map: Record<string, Arrival[]> = {}
|
||||||
for (const r of results) {
|
for (const r of results) map[r.stopId] = r.arrivals
|
||||||
map[r.stopId] = r.arrivals.filter(a => ALLOWED_ROUTES.has(a.route))
|
|
||||||
}
|
|
||||||
setData(map)
|
setData(map)
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
@@ -213,15 +128,112 @@ export default function TransportWidget() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(236,72,153,0.04))',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)' as any,
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: 20, padding: '16px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Glow */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -60, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
width: 280, height: 120, borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(99,102,241,0.25) 0%, transparent 60%)',
|
||||||
|
opacity: 0.35, pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 10,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--text-primary)', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Train size={16} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.2px' }}>
|
||||||
|
Трамвай
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
Ул. Антонова-Овсеенко
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '58px 1fr 1fr',
|
||||||
|
gap: 10,
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
}}>
|
||||||
|
<div />
|
||||||
{DIRECTIONS.map(d => (
|
{DIRECTIONS.map(d => (
|
||||||
<DirectionCard
|
<div key={d.stopId} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 4px' }}>
|
||||||
key={d.stopId}
|
<ArrowRight size={12} color="var(--text-tertiary)" style={{ flexShrink: 0 }} />
|
||||||
dir={d}
|
<div style={{ minWidth: 0 }}>
|
||||||
arrivals={data[d.stopId] || []}
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
loading={loading}
|
{d.short}
|
||||||
/>
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 1 }}>
|
||||||
|
{d.sub}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rows: one per route */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, position: 'relative', zIndex: 1 }}>
|
||||||
|
{ROUTES.map(route => (
|
||||||
|
<div key={route.num} style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '58px 1fr 1fr',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'stretch',
|
||||||
|
}}>
|
||||||
|
{/* Route badge */}
|
||||||
|
<div style={{
|
||||||
|
background: route.bg,
|
||||||
|
boxShadow: `0 6px 16px ${route.color}35`,
|
||||||
|
borderRadius: 12,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'white', fontWeight: 800, fontSize: 20,
|
||||||
|
letterSpacing: '-1px',
|
||||||
|
minHeight: 52,
|
||||||
|
}}>
|
||||||
|
{route.num}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{DIRECTIONS.map(d => {
|
||||||
|
const arrivals = (data[d.stopId] || []).filter(a => a.route === route.num)
|
||||||
|
return (
|
||||||
|
<AnimatePresence key={d.stopId} mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={`${route.num}-${d.stopId}-${arrivals.map(a => a.minutes).join(',')}`}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Cell arrivals={arrivals} color={route.color} />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && Object.keys(data).length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center', position: 'relative', zIndex: 1 }}>
|
||||||
|
Загрузка расписания...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user