diff --git a/web/src/board/layout/align.ts b/web/src/board/layout/align.ts new file mode 100644 index 0000000..c506da6 --- /dev/null +++ b/web/src/board/layout/align.ts @@ -0,0 +1,22 @@ +import { SNAP } from "../constants.ts"; +import type { Rect } from "./layout.ts"; + +export interface Aligned { x: number; y: number; w: number; h: number; snappedX: boolean; snappedY: boolean; guideX: number | null; guideY: number | null } + +// примагничивание к краям и центрам соседей в пределах SNAP px; при изменении размера — только правый и нижний край. +// Направляющие возвращаются координатами (рисует компонент) +export function alignEdges(pos: Record, key: string, r: Rect, resizing: boolean): Aligned { + const xs: number[] = [], ys: number[] = []; + Object.entries(pos).forEach(([k, q]) => { if (k !== key) { xs.push(q.x, q.x + q.w / 2, q.x + q.w); ys.push(q.y, q.y + q.h / 2, q.y + q.h); } }); + const mine = resizing ? { x: [r.x + r.w], y: [r.y + r.h] } : { x: [r.x, r.x + r.w / 2, r.x + r.w], y: [r.y, r.y + r.h / 2, r.y + r.h] }; + const nearest = (vals: number[], targets: number[]) => { + let best: { d: number; t: number } | null = null; + vals.forEach((v) => targets.forEach((t) => { const d = t - v; if (Math.abs(d) <= SNAP && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, t }; })); + return best as { d: number; t: number } | null; + }; + const bx = nearest(mine.x, xs), by = nearest(mine.y, ys); + const out: Aligned = { x: r.x, y: r.y, w: r.w, h: r.h, snappedX: !!bx, snappedY: !!by, guideX: bx ? bx.t : null, guideY: by ? by.t : null }; + if (bx) { if (resizing) out.w += bx.d; else out.x += bx.d; } + if (by) { if (resizing) out.h += by.d; else out.y += by.d; } + return out; +} diff --git a/web/src/board/layout/edges.ts b/web/src/board/layout/edges.ts new file mode 100644 index 0000000..751efce --- /dev/null +++ b/web/src/board/layout/edges.ts @@ -0,0 +1,39 @@ +import { EDGE_R } from "../constants.ts"; +import type { Rect } from "./layout.ts"; + +type Pt = { x: number; y: number }; + +// стрелка от блокера к зависимой — ломаная из горизонтальных и вертикальных отрезков со скруглёнными углами: выходит из карточки +// и входит в карточку перпендикулярно грани +export function orthoPath(pts: Pt[]): string { + let d = `M${pts[0].x} ${pts[0].y}`; + for (let i = 1; i < pts.length - 1; i++) { + const A = pts[i - 1], P = pts[i], B = pts[i + 1]; + const l1 = Math.hypot(P.x - A.x, P.y - A.y), l2 = Math.hypot(B.x - P.x, B.y - P.y), r = Math.min(EDGE_R, l1 / 2, l2 / 2); + if (r < 1) { d += ` L${P.x} ${P.y}`; continue; } + const u1 = { x: (P.x - A.x) / l1, y: (P.y - A.y) / l1 }, u2 = { x: (B.x - P.x) / l2, y: (B.y - P.y) / l2 }; + d += ` L${P.x - u1.x * r} ${P.y - u1.y * r} Q${P.x} ${P.y} ${P.x + u2.x * r} ${P.y + u2.y * r}`; + } + const E = pts[pts.length - 1]; + return d + ` L${E.x} ${E.y}`; +} + +// карточки разнесены по горизонтали — через середину промежутка; иначе по вертикали через середину промежутка; +// перекрываются и по вертикали — обход справа от обеих +export function edgePath(a: Rect, b: Rect): string { + if (b.x >= a.x + a.w + 20) { + const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); + return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); + } + if (a.x >= b.x + b.w + 20) { + const x1 = a.x, y1 = a.y + a.h / 2, x2 = b.x + b.w, y2 = b.y + b.h / 2, xm = Math.round((x1 + x2) / 2); + return orthoPath(y1 === y2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: xm, y: y1 }, { x: xm, y: y2 }, { x: x2, y: y2 }]); + } + const overlapY = b.y < a.y + a.h + 20 && b.y + b.h + 20 > a.y; + if (overlapY) { + const y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, xr = Math.max(a.x + a.w, b.x + b.w) + 28; + return orthoPath([{ x: a.x + a.w, y: y1 }, { x: xr, y: y1 }, { x: xr, y: y2 }, { x: b.x + b.w, y: y2 }]); + } + const down = b.y >= a.y, x1 = a.x + a.w / 2, y1 = down ? a.y + a.h : a.y, x2 = b.x + b.w / 2, y2 = down ? b.y : b.y + b.h, ym = Math.round((y1 + y2) / 2); + return orthoPath(x1 === x2 ? [{ x: x1, y: y1 }, { x: x2, y: y2 }] : [{ x: x1, y: y1 }, { x: x1, y: ym }, { x: x2, y: ym }, { x: x2, y: y2 }]); +} diff --git a/web/src/board/layout/layout.test.ts b/web/src/board/layout/layout.test.ts new file mode 100644 index 0000000..512869f --- /dev/null +++ b/web/src/board/layout/layout.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { BoardSnapshot } from "../../api/types.ts"; +import { canon } from "../../test/canon.ts"; +import { computeVisible } from "../analysis/visible.ts"; +import { filtersOf } from "../../test/boardScenarios.ts"; +import { dictsFromSettings } from "../dicts.ts"; +import settings from "../__fixtures__/settings.json"; +import scenarios from "../__fixtures__/scenarios.json"; +import { fromHub } from "../model/fromHub.ts"; +import { alignEdges } from "./align.ts"; +import { edgePath, orthoPath } from "./edges.ts"; +import { computeLayout, dateText, dateTitle, lineClamp, measuredHeight, place, type Rect } from "./layout.ts"; + +const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8"); +const golden = JSON.parse(fx("hub-basic.expected.json")); +const geo = JSON.parse(fx("geometry.cases.json")); +const geoG = JSON.parse(fx("geometry.expected.json")); +const { dicts } = dictsFromSettings(settings); +const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts); +const TODAY = "2026-10-14"; +const measure = () => ({ h: measuredHeight(117), other: 117 - 38 }); // как заглушка DOM генератора эталонов + +type Sc = { name: string; filters: Record; laneOrder?: string[]; folded?: string[]; + saved?: unknown; savedDate?: unknown; colWidths?: Record }; + +describe("раскладка = links.html@3931a88", () => { + for (const sc of scenarios as Sc[]) { + it(`computeLayout + place: ${sc.name}`, () => { + const f = filtersOf(sc); + const vis = computeVisible(model, f, sc.laneOrder ?? [], TODAY); + const g = golden.scenarios[sc.name].layout; + if (!vis.nodes.length) { expect(g.lanes).toEqual([]); return; } + const lay = computeLayout(vis, { byDate: f.byDate, colWidths: sc.colWidths ?? {}, + saved: f.byDate ? sc.savedDate ?? null : sc.saved ?? null, today: TODAY, measure }); + const placed = place(lay, lay.rel, new Set(sc.folded ?? [])); + expect(canon(lay.rel)).toEqual(g.rel); + expect(canon(lay.laneOf)).toEqual(g.laneOf); + expect(canon(lay.colBounds)).toEqual(g.colBounds); + expect(lay.baseW).toBe(g.baseW); + expect(canon(placed.pos)).toEqual(g.pos); + expect(canon(placed.lanes)).toEqual(g.lanes); + expect({ width: placed.width + "px", height: placed.height + "px" }).toEqual(g.board); + expect(Object.fromEntries(Object.keys(lay.rel).map((k) => [k, placed.pos[k] ? lineClamp(lay.rel[k]) : "unset"]))) + .toEqual(g.clamp); + const cls = (c: { cls: string }) => (c.cls ? " " + c.cls : ""); + expect((lay.dateCols ?? []).map((c) => ({ className: "dcol" + cls(c), left: c.x0 + "px", width: c.w + "px" }))) + .toEqual(g.dateCols); + // вне «по датам» эталон хранит подписи прошлого сценария (генератор не чистит полосу дат; она скрыта) — сравниваем только «по датам» + if (f.byDate) expect((lay.dateCols ?? []).map((c) => ({ className: "dlabel" + cls(c), left: c.x0 + 8 + "px", title: dateTitle(c), + innerHTML: `${dateText(c)}${c.count}` }))).toEqual(g.dateLabels); + const edges = vis.links.filter((l) => placed.pos[l.src] && placed.pos[l.dst]) + .map((l) => ({ src: l.src, dst: l.dst, type: l.type, hand: l.hand, crit: l.crit, d: edgePath(placed.pos[l.src], placed.pos[l.dst]) })); + expect(canon(edges)).toEqual(g.edges); + }); + } +}); + +describe("геометрия = links.html@3931a88", () => { + it("edgePath и orthoPath", () => { + expect(geo.edge.map((c: { a: Rect; b: Rect }) => edgePath(c.a, c.b))).toEqual(geoG.edge); + expect(geo.ortho.map((p: { x: number; y: number }[]) => orthoPath(p))).toEqual(geoG.ortho); + }); + it("alignEdges и направляющие", () => { + geo.align.forEach((c: { pos: Record; key: string; r: Rect; resizing: boolean }, i: number) => { + const { guideX, guideY, ...out } = alignEdges(c.pos, c.key, c.r, c.resizing); + expect(out).toEqual(geoG.align[i].out); + const guides = [...(guideX === null ? [] : [{ x1: guideX, y1: 0, x2: guideX, y2: 0 }]), + ...(guideY === null ? [] : [{ x1: 0, y1: guideY, x2: 0, y2: guideY }])]; + expect(guides).toEqual(geoG.align[i].guides); + }); + }); +}); diff --git a/web/src/board/layout/layout.ts b/web/src/board/layout/layout.ts new file mode 100644 index 0000000..0a91fa1 --- /dev/null +++ b/web/src/board/layout/layout.ts @@ -0,0 +1,128 @@ +import { COLW, CPAD, CW, FOLD_H, GRID, LEFT, MAXROWS, MINH, MINW, TITLE_LH, TOP } from "../constants.ts"; +import { fmtDate } from "../model/util.ts"; +import type { SavedBox, Visible } from "../types.ts"; + +export interface Rect { x: number; y: number; w: number; h: number } +export interface RelBox { x: number; ry: number; w: number; h: number; auto: boolean; other?: number } +export interface DateCol { date: string; x0: number; w: number; count: number; cls: "" | "past" | "today" | "none" } +export interface Layout { + rel: Record; // раскладка карточек относительно дорожек (начальная; правки — у вызывающего) + laneOf: Record; // индекс дорожки карточки + colBounds: Record; // колонка даты карточки (только «по датам») + baseW: number; // ширина доски по слоям + laneNames: string[]; + dateCols: DateCol[] | null; // колонки дат («по датам»), иначе null + multiSprint: boolean; // на доске больше одного спринта — карточкам нужна метка спринта +} +export interface LaneGeom { name: string; y: number; h: number; minH: number; folded: boolean } +export interface Placed { lanes: LaneGeom[]; pos: Record; width: number; height: number } + +export const measuredHeight = (offsetHeight: number): number => Math.max(MINH, Math.ceil(offsetHeight / GRID) * GRID); + +// в режиме «по датам» карточка не выходит за свою колонку даты: x и ширина зажимаются по границам колонки +export function clampToCol(colBounds: Layout["colBounds"], key: string, x: number, w: number): { x: number; w: number } { + const b = colBounds[key]; + if (!b) return { x, w }; + w = Math.max(MINW, Math.min(w, b.x1 - b.x0 - 2 * CPAD)); + return { x: Math.max(b.x0 + CPAD, Math.min(x, b.x1 - CPAD - w)), w }; +} + +export function computeLayout(vis: Visible, o: { + byDate: boolean; colWidths: Record; saved: unknown; today: string; + measure: (key: string) => { h: number; other: number }; +}): Layout { + const rel: Record = {}, laneOf: Record = {}, colBounds: Layout["colBounds"] = {}; + const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n])); + const preds: Record = {}; + vis.nodes.forEach((n) => { preds[n.key] = []; }); + vis.links.forEach((l) => preds[l.dst].push(l.src)); + const layer: Record = {}; + const L = (k: string, seen = new Set()): number => { + if (layer[k] !== undefined) return layer[k]; + if (seen.has(k)) return 0; + seen.add(k); + layer[k] = preds[k].length ? Math.max(...preds[k].map((p) => L(p, seen))) + 1 : 0; + return layer[k]; + }; + const dates = o.byDate ? [...new Set(vis.nodes.map((n) => n.due || ""))].sort((a, b) => (a || "~").localeCompare(b || "~")) : null; + if (dates) vis.nodes.forEach((n) => { layer[n.key] = dates.indexOf(n.due || ""); }); else vis.nodes.forEach((n) => L(n.key)); + const maxLayer = Math.max(0, ...Object.values(layer)), layerCols: number[] = Array(maxLayer + 1).fill(1); + vis.lanes.forEach((lane) => { + const cnt: Record = {}; + lane.keys.forEach((k) => { cnt[layer[k]] = (cnt[layer[k]] || 0) + 1; }); + Object.entries(cnt).forEach(([c, n]) => { layerCols[+c] = Math.max(layerCols[+c], Math.ceil(n / MAXROWS)); }); + }); + const cw = dates ? o.colWidths : {}; + // ручная ширина не уже автоматической + const colW = (c: number) => (dates && typeof cw[dates[c]] === "number" ? Math.max(layerCols[c] * COLW, cw[dates[c]]) : layerCols[c] * COLW); + const layerX: number[] = []; + let x = LEFT; + for (let c = 0; c <= maxLayer; c++) { layerX[c] = x; x += colW(c); } + const baseW = x; + const stacks: string[][] = []; // подколонки: карточки сверху вниз + vis.lanes.forEach((lane, li) => { + const cols: Record = {}; + lane.keys.forEach((k) => { (cols[layer[k]] = cols[layer[k]] || []).push(k); laneOf[k] = li; }); + Object.entries(cols).forEach(([c, ks]) => { + ks.sort((a, b) => ((byKey[a].due || "9") > (byKey[b].due || "9") ? 1 : -1)); + const per = Math.ceil(ks.length / Math.ceil(ks.length / MAXROWS)), sub: string[][] = []; + ks.forEach((k, i) => { + const col = Math.floor(i / per); + rel[k] = { x: layerX[+c] + col * COLW, ry: TOP, w: CW, h: MINH, auto: true }; + (sub[col] = sub[col] || []).push(k); + }); + stacks.push(...sub); + }); + }); + let dateCols: DateCol[] | null = null; + if (dates) { + dateCols = dates.map((d, c) => ({ + date: d, x0: layerX[c] - LEFT / 2, w: colW(c), count: vis.nodes.filter((n) => (n.due || "") === d).length, + cls: d ? (d < o.today ? "past" : d === o.today ? "today" : "") : "none", + })); + vis.nodes.forEach((n) => { const c = layer[n.key], x0 = layerX[c] - LEFT / 2; colBounds[n.key] = { x0, x1: x0 + colW(c) }; }); + } + const multiSprint = new Set(vis.nodes.filter((n) => !n.stub).map((n) => n.sprint)).size > 1; + // высота по содержимому; other — высота всего, кроме заголовка (для обрезки заголовка при ручной высоте) + vis.nodes.forEach((n) => { const m = o.measure(n.key); rel[n.key].h = m.h; rel[n.key].other = m.other; }); + stacks.forEach((list) => { let y = TOP; list.forEach((k) => { rel[k].ry = y; y += rel[k].h + 16; }); }); + const saved = o.saved; + if (saved && typeof saved === "object") { + Object.entries(saved as Record).forEach(([k, v]) => { + if (!rel[k] || !v || typeof v.x !== "number" || typeof v.ry !== "number") return; + const base = colBounds[k] ? colBounds[k].x0 : 0; + const manualH = typeof v.h === "number"; + // дата могла смениться — карточка остаётся в своей колонке + const c = clampToCol(colBounds, k, Math.max(0, base + v.x), typeof v.w === "number" ? v.w : rel[k].w); + rel[k] = { x: c.x, ry: Math.max(TOP, v.ry), w: c.w, h: manualH ? (v.h as number) : rel[k].h, auto: !manualH, other: rel[k].other }; + }); + } + return { rel, laneOf, colBounds, baseW, laneNames: vis.lanes.map((l) => l.name), dateCols, multiSprint }; +} + +// координаты дорожек и карточек по rel: дорожка растёт под карточки, свёрнутая — только шапка, её карточки в pos не попадают +export function place(layout: Layout, rel: Record, folded: Set): Placed { + let yy = 0, w = layout.baseW; + const pos: Record = {}; + const lanes: LaneGeom[] = layout.laneNames.map((name) => ({ name, y: 0, h: 0, minH: TOP + MINH + 24, folded: false })); + lanes.forEach((g, li) => { + g.folded = folded.has(g.name); + let h = g.folded ? FOLD_H : g.minH; + if (!g.folded) Object.entries(rel).forEach(([k, r]) => { if (layout.laneOf[k] === li) { h = Math.max(h, r.ry + r.h + 12); w = Math.max(w, r.x + r.w + LEFT); } }); + g.y = yy; g.h = h; yy += h; + }); + Object.entries(rel).forEach(([k, r]) => { + const g = lanes[layout.laneOf[k]]; + if (g.folded) return; + pos[k] = { x: r.x, y: g.y + r.ry, w: r.w, h: r.h }; + }); + return { lanes, pos, width: w, height: yy }; +} + +// число строк заголовка при ручной высоте; «unset» — высота авто +export const lineClamp = (r: RelBox): string => (r.auto ? "unset" : String(Math.max(1, Math.floor((r.h - (r.other || 112)) / TITLE_LH)))); + +// подсказка подписи колонки даты +export const dateTitle = (c: DateCol): string => + c.date ? (c.cls === "past" ? "срок прошёл" : c.cls === "today" ? "сегодня" : "плановая дата окончания") : "плановая дата не задана"; +export const dateText = (c: DateCol): string => (c.date ? fmtDate(c.date) : "без срока");