feat(web): раскладка доски, стрелки и примагничивание — без DOM (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7065cbeaef
commit
232c290f9b
22
web/src/board/layout/align.ts
Normal file
22
web/src/board/layout/align.ts
Normal file
@ -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<string, Rect>, 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;
|
||||
}
|
||||
39
web/src/board/layout/edges.ts
Normal file
39
web/src/board/layout/edges.ts
Normal file
@ -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 }]);
|
||||
}
|
||||
74
web/src/board/layout/layout.test.ts
Normal file
74
web/src/board/layout/layout.test.ts
Normal file
@ -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<string, unknown>; laneOrder?: string[]; folded?: string[];
|
||||
saved?: unknown; savedDate?: unknown; colWidths?: Record<string, number> };
|
||||
|
||||
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)}<span class="n">${c.count}</span>` }))).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<string, Rect>; 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
web/src/board/layout/layout.ts
Normal file
128
web/src/board/layout/layout.ts
Normal file
@ -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<string, RelBox>; // раскладка карточек относительно дорожек (начальная; правки — у вызывающего)
|
||||
laneOf: Record<string, number>; // индекс дорожки карточки
|
||||
colBounds: Record<string, { x0: number; x1: number }>; // колонка даты карточки (только «по датам»)
|
||||
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<string, Rect>; 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<string, number>; saved: unknown; today: string;
|
||||
measure: (key: string) => { h: number; other: number };
|
||||
}): Layout {
|
||||
const rel: Record<string, RelBox> = {}, laneOf: Record<string, number> = {}, colBounds: Layout["colBounds"] = {};
|
||||
const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n]));
|
||||
const preds: Record<string, string[]> = {};
|
||||
vis.nodes.forEach((n) => { preds[n.key] = []; });
|
||||
vis.links.forEach((l) => preds[l.dst].push(l.src));
|
||||
const layer: Record<string, number> = {};
|
||||
const L = (k: string, seen = new Set<string>()): 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<number, number> = {};
|
||||
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<number, string[]> = {};
|
||||
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<string, SavedBox>).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<string, RelBox>, folded: Set<string>): Placed {
|
||||
let yy = 0, w = layout.baseW;
|
||||
const pos: Record<string, Rect> = {};
|
||||
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) : "без срока");
|
||||
Loading…
x
Reference in New Issue
Block a user