diff --git a/apps/design-lab/e2e/pages.spec.ts b/apps/design-lab/e2e/pages.spec.ts index 88ff1e3..f478fa9 100644 --- a/apps/design-lab/e2e/pages.spec.ts +++ b/apps/design-lab/e2e/pages.spec.ts @@ -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')) diff --git a/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png b/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png index 7f5d1e3..f7fc434 100644 Binary files a/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png and b/apps/design-lab/e2e/visual.spec.ts-snapshots/shelf-chromium-linux.png differ diff --git a/apps/design-lab/src/components/Scatter.tsx b/apps/design-lab/src/components/Scatter.tsx new file mode 100644 index 0000000..bde9a9e --- /dev/null +++ b/apps/design-lab/src/components/Scatter.tsx @@ -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(null) + const [places, setPlaces] = useState([]) + + 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 ( +
+ {places.map((p, i) => ( +
+ {shapes[i]} +
+ ))} +
+ ) +} diff --git a/apps/design-lab/src/pages/shelf/App.tsx b/apps/design-lab/src/pages/shelf/App.tsx index 10e5b2e..2be34d1 100644 --- a/apps/design-lab/src/pages/shelf/App.tsx +++ b/apps/design-lab/src/pages/shelf/App.tsx @@ -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: , x: 470, y: 14 }, - { el: , x: 830, y: 150 }, - { el: , x: 640, y: 440 }, - { el: , x: 828, y: 218 }, - { el: , x: 40, y: 316 }, - { el: , x: 262, y: 548 }, - { el: , x: 96, y: 496 }, -] - export function App() { return ( - -
- - {SCATTER.map((s, i) => ( -
{s.el}
- ))} + + +
+ , + , + , + , + , + , + , + ]} + /> +
-
-
-
-

代理区:安装交给 agent,只验收产物

-
价值区:过程就地写清
-
+
+
+ +
-

- 形状散落于空白,每个都有含义:▲ 靶子 · ● 积木 · ○ 预期 · ~ 代理区 · ▬ 价值区 · ⌇ 脱靶 · + 指派;线:实=先后 · 虚=相关 · 点=同族 -

) diff --git a/apps/design-lab/src/pages/symbols/App.tsx b/apps/design-lab/src/pages/symbols/App.tsx index 6cb49a1..c48b542 100644 --- a/apps/design-lab/src/pages/symbols/App.tsx +++ b/apps/design-lab/src/pages/symbols/App.tsx @@ -30,7 +30,7 @@ const LINES = [ export function App() { return ( -
+
{SHAPES.map((s) => (
diff --git a/docs/screenshots/design-lab/pr_shelf.png b/docs/screenshots/design-lab/pr_shelf.png index 7f5d1e3..f7fc434 100644 Binary files a/docs/screenshots/design-lab/pr_shelf.png and b/docs/screenshots/design-lab/pr_shelf.png differ