refactor: 样例页改纯组合场景(全组件真实使用,删解释性标注),新增 Scatter——形状散落=卡片附近空当+种子随机旋转/落点;E2E 加摆放契约
This commit is contained in:
1 parent
e59db8c199
commit
6cb91284f0
6 files changed
+158
-28
No files matched your search
@@ -139,6 +139,44 @@ test.describe('RouteLine 规格契约', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('散落形状摆放契约', () => {
|
||||
test.beforeEach(async ({ page }) => await page.goto('/shelf.html'))
|
||||
|
||||
test('卡片附近空当+随机:不压卡、都在卡附近、不出容器、旋转各异', async ({ page }) => {
|
||||
const r = await page.evaluate(() => {
|
||||
const layer = document.querySelector('[data-scatter-layer]') as HTMLElement | null
|
||||
if (!layer) return null
|
||||
const host = layer.parentElement as HTMLElement
|
||||
const hb = host.getBoundingClientRect()
|
||||
const shapes = [...layer.querySelectorAll('[data-scatter]')] as HTMLElement[]
|
||||
const comps = [...host.querySelectorAll('[data-comp]')].map((el) => el.getBoundingClientRect())
|
||||
const hit = (a: DOMRect, b: DOMRect) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
||||
return {
|
||||
n: shapes.length,
|
||||
inHost: shapes.every((s) => {
|
||||
const r = s.getBoundingClientRect()
|
||||
return r.left >= hb.left - 1 && r.right <= hb.right + 1 && r.top >= hb.top - 1 && r.bottom <= hb.bottom + 1
|
||||
}),
|
||||
onComp: shapes.filter((s) => comps.some((c) => hit(s.getBoundingClientRect(), c))).length,
|
||||
nearAll: shapes.every((s) => {
|
||||
const r = s.getBoundingClientRect()
|
||||
const cx = (r.left + r.right) / 2
|
||||
const cy = (r.top + r.bottom) / 2
|
||||
return comps.some((c) => cx >= c.left - 200 && cx <= c.right + 200 && cy >= c.top - 200 && cy <= c.bottom + 200)
|
||||
}),
|
||||
transforms: shapes.map((s) => s.style.transform),
|
||||
}
|
||||
})
|
||||
expect(r).not.toBeNull()
|
||||
expect(r!.n).toBeGreaterThanOrEqual(5)
|
||||
expect(r!.inHost).toBeTruthy() // 不出场景容器
|
||||
expect(r!.onComp).toBe(0) // 空当=不压任何内容盒
|
||||
expect(r!.nearAll).toBeTruthy() // 只散在卡片附近,不胡乱撒
|
||||
expect(new Set(r!.transforms).size).toBe(r!.n) // 落点/旋转各不相同
|
||||
r!.transforms.forEach((t) => expect(t).toMatch(/^translate\(-50%, -50%\) rotate\(-?\d+deg\)$/))
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('票券规格契约', () => {
|
||||
test.beforeEach(async ({ page }) => await page.goto('/symbols.html'))
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,90 @@
|
||||
/** 散落形状摆放——「卡片附近空当 + 随机」:
|
||||
* 网格采样容器空白 → 过滤出距内容盒([data-comp])≤ near 的空当(不胡乱撒)→
|
||||
* 种子随机挑位(彼此保距)+空当内随机偏移+随机旋转。
|
||||
* 种子固定 → 每次渲染一致(视觉基线可测);容器重排(ResizeObserver)自动重算。 */
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let t = seed >>> 0
|
||||
return () => {
|
||||
t = (t + 0x6d2b79f5) >>> 0
|
||||
let x = Math.imul(t ^ (t >>> 15), 1 | t)
|
||||
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x
|
||||
return ((x ^ (x >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
type Place = { x: number; y: number; rot: number }
|
||||
|
||||
export function Scatter({
|
||||
shapes,
|
||||
seed = 7,
|
||||
near = 150, // 空当须距某内容盒 ≤ near——只散在卡片附近
|
||||
margin = 34, // 与内容盒保持的最小间隙(≥ 最大形状半对角)
|
||||
step = 30, // 采样网格密度
|
||||
minGap = 80, // 形状彼此最小间距
|
||||
}: {
|
||||
shapes: ReactNode[]
|
||||
seed?: number
|
||||
near?: number
|
||||
margin?: number
|
||||
step?: number
|
||||
minGap?: number
|
||||
}) {
|
||||
const layerRef = useRef<HTMLDivElement>(null)
|
||||
const [places, setPlaces] = useState<Place[]>([])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const layer = layerRef.current
|
||||
const host = layer?.parentElement
|
||||
if (!layer || !host) return
|
||||
|
||||
const place = () => {
|
||||
const hb = host.getBoundingClientRect()
|
||||
const rects = [...host.querySelectorAll('[data-comp]')].map((el) => el.getBoundingClientRect())
|
||||
if (!rects.length || !hb.width) return
|
||||
const dist = (x: number, y: number, r: DOMRect) =>
|
||||
Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom))
|
||||
const cand: { x: number; y: number }[] = []
|
||||
for (let x = 36; x < hb.width - 36; x += step)
|
||||
for (let y = 36; y < hb.height - 36; y += step) {
|
||||
const px = hb.left + x
|
||||
const py = hb.top + y
|
||||
if (rects.every((r) => dist(px, py, r) >= margin) && rects.some((r) => dist(px, py, r) <= near))
|
||||
cand.push({ x, y })
|
||||
}
|
||||
const rnd = mulberry32(seed)
|
||||
for (let i = cand.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rnd() * (i + 1))
|
||||
;[cand[i], cand[j]] = [cand[j], cand[i]]
|
||||
}
|
||||
const taken: Place[] = []
|
||||
for (const c of cand) {
|
||||
if (taken.length >= shapes.length) break
|
||||
if (taken.some((t) => Math.hypot(t.x - c.x, t.y - c.y) < minGap)) continue
|
||||
taken.push({ x: c.x + (rnd() - 0.5) * 14, y: c.y + (rnd() - 0.5) * 14, rot: Math.round((rnd() - 0.5) * 52) })
|
||||
}
|
||||
setPlaces(taken)
|
||||
}
|
||||
|
||||
place()
|
||||
const ro = new ResizeObserver(place)
|
||||
ro.observe(host)
|
||||
return () => ro.disconnect()
|
||||
}, [seed, near, margin, step, minGap, shapes.length])
|
||||
|
||||
return (
|
||||
<div ref={layerRef} data-scatter-layer aria-hidden className="pointer-events-none absolute inset-0">
|
||||
{places.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-scatter
|
||||
className="absolute"
|
||||
style={{ left: p.x, top: p.y, transform: `translate(-50%, -50%) rotate(${p.rot}deg)` }}
|
||||
>
|
||||
{shapes[i]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
/** 组合场景=大部分组件拿出来的真实使用,不做解释性标注:
|
||||
* 入口画像 → 课程卡与关系线 → 进度票券 → 指派块;形状散落于卡片附近空当。 */
|
||||
import { Page } from '../../lib/ui'
|
||||
import { ASSIGNMENT, CHAPTERS, EDGES } from '../../lib/chapters'
|
||||
import { MemphisCard } from '../../components/MemphisCard'
|
||||
import { RouteLine } from '../../components/RouteLine'
|
||||
import { AssignBlock } from '../../components/Ticket'
|
||||
import { Bar, Cross, MissMark, Ring, Tremble, Tri, Wave } from '../../components/shapes'
|
||||
import { AssignBlock, Ticket } from '../../components/Ticket'
|
||||
import { EntryGuide } from '../../components/EntryGuide'
|
||||
import { Scatter } from '../../components/Scatter'
|
||||
import { Bar, Cross, Ring, Tremble, Tri, Wave } from '../../components/shapes'
|
||||
|
||||
/* 场景坐标(复刻已验收组合):卡1(40,110,250) 卡2(380,44,250,rot) 卡3(700,44,140) */
|
||||
const nodes = {
|
||||
@@ -12,37 +16,35 @@ const nodes = {
|
||||
5: { id: 5, borderLeft: 698, right: 848, y: 108 },
|
||||
} as const
|
||||
|
||||
const SCATTER = [
|
||||
{ el: <Tri size={26} />, x: 470, y: 14 },
|
||||
{ el: <Ring size={22} />, x: 830, y: 150 },
|
||||
{ el: <Cross size={20} />, x: 640, y: 440 },
|
||||
{ el: <Cross size={18} />, x: 828, y: 218 },
|
||||
{ el: <Wave size={44} />, x: 40, y: 316 },
|
||||
{ el: <Bar size={30} />, x: 262, y: 548 },
|
||||
{ el: <Tremble size={20} />, x: 96, y: 496 },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Page tag="<Examples />" title="样例 · 组合场景" desc="全系统同台:拟物卡+三种关系线+指派块与脱靶标记+散落语义形状——实现验收的对照图。">
|
||||
<div className="relative h-[620px] overflow-hidden rounded-2xl bg-paper">
|
||||
<RouteLine edges={EDGES} nodes={nodes} width={1160} height={620} />
|
||||
{SCATTER.map((s, i) => (
|
||||
<div key={i} className="absolute" style={{ left: s.x, top: s.y }}>{s.el}</div>
|
||||
))}
|
||||
<Page
|
||||
tag="<Examples />"
|
||||
title="样例 · 组合场景"
|
||||
desc="大部分组件同台真实使用:入口画像 → 课程卡与关系线 → 进度票券 → 指派块;形状散落于卡片附近空当。"
|
||||
>
|
||||
<EntryGuide />
|
||||
<div className="relative mt-8 h-[600px] overflow-hidden rounded-2xl bg-paper">
|
||||
<Scatter
|
||||
shapes={[
|
||||
<Tri size={26} key="tri" />,
|
||||
<Ring size={22} key="ring" />,
|
||||
<Cross size={20} key="cross1" />,
|
||||
<Cross size={18} key="cross2" />,
|
||||
<Wave size={44} key="wave" />,
|
||||
<Bar size={30} key="bar" />,
|
||||
<Tremble size={20} key="tremble" />,
|
||||
]}
|
||||
/>
|
||||
<RouteLine edges={EDGES} nodes={nodes} width={1160} height={600} />
|
||||
<div className="absolute left-[40px] top-[110px] w-[250px]"><MemphisCard chapter={CHAPTERS[1]} entry={1} /></div>
|
||||
<div className="absolute left-[380px] top-[44px] w-[250px] -rotate-1"><MemphisCard chapter={CHAPTERS[2]} entry={2} /></div>
|
||||
<div className="absolute left-[700px] top-[44px] w-[140px]"><MemphisCard chapter={CHAPTERS[5]} compact /></div>
|
||||
<div className="absolute left-[380px] top-[300px]"><AssignBlock {...ASSIGNMENT} /></div>
|
||||
<div className="absolute left-[60px] top-[420px] w-[280px]">
|
||||
<div className="mb-2"><Wave size={40} /></div>
|
||||
<p className="m-0 text-[11px] text-body"><b>代理区</b>:安装交给 agent,只验收产物</p>
|
||||
<div className="mt-3 flex items-center gap-2"><Bar size={30} /><span className="text-[11px] text-body"><b>价值区</b>:过程就地写清</span></div>
|
||||
<div className="mt-6"><MissMark how="装了但没跑" looks={ASSIGNMENT.miss.looks} /></div>
|
||||
<div className="absolute left-[860px] top-[320px]"><AssignBlock {...ASSIGNMENT} /></div>
|
||||
<div className="absolute left-[60px] top-[440px] flex gap-4">
|
||||
<Ticket chapter={CHAPTERS[2]} done />
|
||||
<Ticket chapter={CHAPTERS[5]} done={false} />
|
||||
</div>
|
||||
<p className="absolute bottom-4 left-[40px] m-0 text-[10.5px] text-mute">
|
||||
形状散落于空白,每个都有含义:▲ 靶子 · ● 积木 · ○ 预期 · ~ 代理区 · ▬ 价值区 · ⌇ 脱靶 · + 指派;线:实=先后 · 虚=相关 · 点=同族
|
||||
</p>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ const LINES = [
|
||||
export function App() {
|
||||
return (
|
||||
<Page tag="<Symbols />" title="符号、形状 · 装饰即语义" desc="形状对内容下定义、散落于空白;线条形式承载关系含义;票券只表达完成状态。">
|
||||
<Section label="语义形状 ×7" note="散落只在空白,密度 ≤ 8/屏">
|
||||
<Section label="语义形状 ×7" note="散落=卡片附近空当+随机旋转/落点(Scatter 种子化),密度 ≤ 8/屏">
|
||||
<div className="grid grid-cols-4 gap-3.5 md:grid-cols-7">
|
||||
{SHAPES.map((s) => (
|
||||
<div key={s.n} className="text-center">
|
||||
|
||||
Reference in new issue
Block a user