feat: hover 卡片时线与站点跟随浮起——RouteLine 增 hovered 联动(站点 translateY(-3) 平滑过渡、连线 CSS d: path() 重算同 150ms),书架场景 hover 状态提升接线;卡片割裂感消除
This commit is contained in:
1 parent
a65a6d96b1
commit
c11f33c6b7
2 files changed
+54
-15
No files matched your search
@@ -1,7 +1,10 @@
|
|||||||
/** RouteLine —— 关系线:线形即含义(实=先后 虚=相关 点=同族)
|
/** RouteLine —— 关系线:线形即含义(实=先后 虚=相关 点=同族)
|
||||||
|
* 卡片联动:hovered 节点随卡浮起 -3px——站点 translateY 平滑跟随,
|
||||||
|
* 连线以 CSS d: path() 过渡重算(与卡片同样 150ms,不支持的浏览器瞬时跟随)
|
||||||
* 拓扑:横平竖直 + R8 直角步进;并行不合并(4px);站点 r6 半嵌卡边
|
* 拓扑:横平竖直 + R8 直角步进;并行不合并(4px);站点 r6 半嵌卡边
|
||||||
* (圆心骑左右描边,各半在卡上);线层 z-10 置于卡之上,站点不被卡遮挡;
|
* (圆心骑左右描边,各半在卡上);线层 z-10 置于卡之上,站点不被卡遮挡;
|
||||||
* 同口多线自动聚合胶囊(线止于胶囊边界)。 */
|
* 同口多线自动聚合胶囊(线止于胶囊边界)。 */
|
||||||
|
import type React from 'react'
|
||||||
import type { ChapterId, Edge } from '../lib/types'
|
import type { ChapterId, Edge } from '../lib/types'
|
||||||
|
|
||||||
const STATION_COLORS = ['var(--color-clash2a)', 'var(--color-clash1b)', 'var(--color-clash3a)', 'var(--color-clash1a)']
|
const STATION_COLORS = ['var(--color-clash2a)', 'var(--color-clash1b)', 'var(--color-clash3a)', 'var(--color-clash1a)']
|
||||||
@@ -23,18 +26,25 @@ function stepPath(x0: number, y0: number, x1: number, y1: number, r = 8): string
|
|||||||
return `M ${x0} ${y0} H ${cornerX} A ${r} ${r} 0 0 ${sweep1} ${cornerX + r} ${y0 + (down ? r : -r)} V ${y1 - (down ? r : -r)} A ${r} ${r} 0 0 ${sweep2} ${cornerX + 2 * r} ${y1} H ${x1}`
|
return `M ${x0} ${y0} H ${cornerX} A ${r} ${r} 0 0 ${sweep1} ${cornerX + r} ${y0 + (down ? r : -r)} V ${y1 - (down ? r : -r)} A ${r} ${r} 0 0 ${sweep2} ${cornerX + 2 * r} ${y1} H ${x1}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOVER_LIFT = -3 // 与 MemphisCard hover:-translate-y-[3px] 一致
|
||||||
|
|
||||||
export function RouteLine({
|
export function RouteLine({
|
||||||
edges,
|
edges,
|
||||||
nodes,
|
nodes,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
hovered = null,
|
||||||
}: {
|
}: {
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
nodes: Partial<Record<ChapterId, LaneNode>>
|
nodes: Partial<Record<ChapterId, LaneNode>>
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
/** 正在浮起的卡(章 id)——线与站点跟随 */
|
||||||
|
hovered?: ChapterId | null
|
||||||
}) {
|
}) {
|
||||||
const stationColor = (i: number) => STATION_COLORS[i % STATION_COLORS.length]
|
const stationColor = (i: number) => STATION_COLORS[i % STATION_COLORS.length]
|
||||||
|
/** 节点跟随位移:hovered 卡 -3px,其余 0 */
|
||||||
|
const dy = (id: ChapterId) => (id === hovered ? HOVER_LIFT : 0)
|
||||||
|
|
||||||
const seqEdges = edges.filter((e) => e.kind === 'seq')
|
const seqEdges = edges.filter((e) => e.kind === 'seq')
|
||||||
const insByTo = new Map<ChapterId, Edge[]>()
|
const insByTo = new Map<ChapterId, Edge[]>()
|
||||||
@@ -55,10 +65,11 @@ export function RouteLine({
|
|||||||
const a = nodes[e.from]
|
const a = nodes[e.from]
|
||||||
const b = nodes[e.to]
|
const b = nodes[e.to]
|
||||||
if (!a || !b) return null
|
if (!a || !b) return null
|
||||||
const d = stepPath(a.right, a.y, b.borderLeft, b.y)
|
const d = stepPath(a.right, a.y + dy(e.from), b.borderLeft, b.y + dy(e.to))
|
||||||
if (e.kind === 'rel') return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2} fill="none" strokeDasharray="4 2" />
|
const follow = { d: `path("${d}")`, transition: 'd 150ms ease' } as React.CSSProperties
|
||||||
if (e.kind === 'family') return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2.4} fill="none" strokeDasharray="0.1 6" strokeLinecap="round" />
|
if (e.kind === 'rel') return <path key={i} d={d} style={follow} stroke="var(--color-ink)" strokeWidth={2} fill="none" strokeDasharray="4 2" />
|
||||||
return <path key={i} d={d} stroke="var(--color-ink)" strokeWidth={2.5} fill="none" />
|
if (e.kind === 'family') return <path key={i} d={d} style={follow} stroke="var(--color-ink)" strokeWidth={2.4} fill="none" strokeDasharray="0.1 6" strokeLinecap="round" />
|
||||||
|
return <path key={i} d={d} style={follow} stroke="var(--color-ink)" strokeWidth={2.5} fill="none" />
|
||||||
})}
|
})}
|
||||||
{seqEdges.map((e) => {
|
{seqEdges.map((e) => {
|
||||||
const a = nodes[e.from]
|
const a = nodes[e.from]
|
||||||
@@ -69,12 +80,18 @@ export function RouteLine({
|
|||||||
// 源/目标站点:圆心骑卡描边,各半在卡上;目标:单线=圆点,多线=胶囊(只画一次)
|
// 源/目标站点:圆心骑卡描边,各半在卡上;目标:单线=圆点,多线=胶囊(只画一次)
|
||||||
return (
|
return (
|
||||||
<g key={`st-${e.from}-${e.to}`}>
|
<g key={`st-${e.from}-${e.to}`}>
|
||||||
<circle cx={a.right} cy={a.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
<g style={{ transform: `translateY(${dy(e.from)}px)`, transition: 'transform 150ms ease' }}>
|
||||||
{capsule ? (
|
<circle cx={a.right} cy={a.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
||||||
<rect x={b.borderLeft - 3} y={b.y - 10} width={6} height={20} rx={3} fill="var(--color-clash1a)" stroke="var(--color-ink)" strokeWidth={2} />
|
</g>
|
||||||
) : ins.length === 1 ? (
|
{(capsule || ins.length === 1) && (
|
||||||
<circle cx={b.borderLeft} cy={b.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
<g style={{ transform: `translateY(${dy(e.to)}px)`, transition: 'transform 150ms ease' }}>
|
||||||
) : null}
|
{capsule ? (
|
||||||
|
<rect x={b.borderLeft - 3} y={b.y - 10} width={6} height={20} rx={3} fill="var(--color-clash1a)" stroke="var(--color-ink)" strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<circle cx={b.borderLeft} cy={b.y} r={6} fill={stationColor(si++)} stroke="var(--color-ink)" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/** 课程书架——教程首页:入口画像 → 六章卡片 → 关系与进度 */
|
/** 课程书架——教程首页:入口画像 → 六章卡片 → 关系与进度
|
||||||
|
* 主线场景:hover 卡片时线与站点跟随浮起(状态提升到场景层) */
|
||||||
|
import { useState } from 'react'
|
||||||
import { Page, Section } from '../../lib/ui'
|
import { Page, Section } from '../../lib/ui'
|
||||||
import { CHAPTERS } from '../../lib/chapters'
|
import { CHAPTERS } from '../../lib/chapters'
|
||||||
import { MemphisCard } from '../../components/MemphisCard'
|
import { MemphisCard } from '../../components/MemphisCard'
|
||||||
@@ -24,6 +26,7 @@ const GRID: { id: ChapterId; entry?: 1 | 2 | 3 }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const [hovered, setHovered] = useState<ChapterId | null>(null)
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
active="index"
|
active="index"
|
||||||
@@ -40,7 +43,7 @@ export function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
<Section label="主线与进度" note="实线=先后;点线=同族;票券=完成状态">
|
<Section label="主线与进度" note="实线=先后;点线=同族;票券=完成状态;hover 卡片,线与站点跟随浮起">
|
||||||
<div className="relative h-[520px] overflow-hidden rounded-2xl bg-paper">
|
<div className="relative h-[520px] overflow-hidden rounded-2xl bg-paper">
|
||||||
<RouteLine
|
<RouteLine
|
||||||
edges={[
|
edges={[
|
||||||
@@ -51,10 +54,29 @@ export function App() {
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
width={1160}
|
width={1160}
|
||||||
height={520}
|
height={520}
|
||||||
|
hovered={hovered}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-[40px] top-[110px] w-[250px]"><MemphisCard chapter={CHAPTERS[1]} /></div>
|
<div
|
||||||
<div className="absolute left-[380px] top-[44px] w-[250px] -rotate-1"><MemphisCard chapter={CHAPTERS[2]} /></div>
|
className="absolute left-[40px] top-[110px] w-[250px]"
|
||||||
<div className="absolute left-[700px] top-[44px] w-[140px]"><MemphisCard chapter={CHAPTERS[5]} compact /></div>
|
onMouseEnter={() => setHovered(1)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
>
|
||||||
|
<MemphisCard chapter={CHAPTERS[1]} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute left-[380px] top-[44px] w-[250px] -rotate-1"
|
||||||
|
onMouseEnter={() => setHovered(2)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
>
|
||||||
|
<MemphisCard chapter={CHAPTERS[2]} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute left-[700px] top-[44px] w-[140px]"
|
||||||
|
onMouseEnter={() => setHovered(5)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
>
|
||||||
|
<MemphisCard chapter={CHAPTERS[5]} compact />
|
||||||
|
</div>
|
||||||
<div className="absolute left-[60px] top-[380px] flex gap-4">
|
<div className="absolute left-[60px] top-[380px] flex gap-4">
|
||||||
<Ticket chapter={CHAPTERS[1]} done />
|
<Ticket chapter={CHAPTERS[1]} done />
|
||||||
<Ticket chapter={CHAPTERS[5]} done={false} />
|
<Ticket chapter={CHAPTERS[5]} done={false} />
|
||||||
|
|||||||
Reference in new issue
Block a user