feat(web): холст доски — дорожки, карточки, стрелки, перетаскивание, колонки дат (#21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 22:55:12 +04:00
parent 3c2c75e8fe
commit 86f5ccc981
7 changed files with 611 additions and 0 deletions

161
web/src/board/board.css Normal file
View File

@ -0,0 +1,161 @@
/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)}
.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px}
.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0}
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.stats b{color:var(--fg);font-weight:500}
/* 5. Строка управления, предупреждения о файле, легенда-фильтр */
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px}
.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px}
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.legend .sep{width:1px;height:14px;background:var(--line)}
.legend .hint{margin-left:auto}
.lg{font:inherit;font-size:13px;color:var(--muted);background:none;border:0;padding:2px 5px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.lg:hover,.lg:focus-visible{background:var(--soft);color:var(--fg);outline:none}
.lg.off{opacity:.45;text-decoration:line-through}
.sw{width:10px;height:10px;border-radius:2px;display:inline-block}
.ln{width:26px;height:0;border-top:2px solid var(--edge-strong);display:inline-block}
.ln.dash{border-top-style:dashed;border-top-color:var(--edge)}
.ln.hand{border-top-color:var(--guide)}
.ln.crit{border-top-width:4px;border-top-color:var(--guide)}
.out-sw{width:16px;height:11px;border:1.5px dashed var(--muted);border-radius:3px;display:inline-block}
/* 6. Доска: прокрутка только по горизонтали (вертикальная — у страницы), дорожки по эпикам, карточки абсолютные */
.stage{display:flex;gap:10px;align-items:stretch}
.wrap{flex:1 1 auto;min-width:0;overflow-x:auto;overflow-y:hidden;border:1px solid var(--line);border-radius:8px;background:var(--card);user-select:none;-webkit-user-select:none}
.board{position:relative;min-width:max(100%,max-content)}
svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
svg.guides{z-index:3}
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.empty{padding:40px 16px;color:var(--muted);text-align:center}
.bar>span:empty{display:none}
#hubState{white-space:normal;min-width:0}
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
.login p{margin:0 0 12px;font-size:14px;color:var(--muted)}
.login p.err{color:var(--p-crit)}
.login code{font-family:var(--font-mono);font-size:13px;color:var(--fg)}
.login form{display:flex;gap:8px;margin:0 0 12px}
.login input{flex:1;min-width:0;font:inherit;font-size:14px;height:34px;box-sizing:border-box;padding:0 10px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px}
.login input:focus-visible{border-color:var(--accent);outline:none}
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--line)}
.lane:nth-child(even of .lane){background:color-mix(in srgb,var(--soft) 45%,transparent)}
.lane.folded{background:color-mix(in srgb,var(--soft) 80%,transparent)}
.lane.target{box-shadow:inset 0 3px 0 var(--accent)}
.lane.target-after{box-shadow:inset 0 -3px 0 var(--accent)}
.lane.dragging{opacity:.6}
.lane-head{position:absolute;left:8px;top:8px;display:flex;align-items:center;gap:4px;z-index:1}
.fold{width:18px;height:18px;border:0;background:none;color:var(--muted);cursor:pointer;padding:0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:0}
.fold::before{content:"";border:5px solid transparent;border-top:6px solid currentColor;border-bottom:0;transition:transform .15s}
.fold:hover{background:var(--soft);color:var(--fg)}
.lane.folded .fold::before{transform:rotate(-90deg)}
.lane-name{font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;cursor:grab;padding:2px 4px;border-radius:4px;user-select:none;touch-action:none}
.lane-name:hover{background:var(--soft);color:var(--fg)}
.lane-count{display:none;font-size:12.5px;color:var(--muted);margin-left:4px}
.lane.folded .lane-count{display:inline}
.lane-tools{display:none;gap:2px}
.lane:hover .lane-tools{display:inline-flex}
.lane-tools button{font:inherit;font-size:12px;line-height:1;color:var(--muted);background:var(--card);border:1px solid var(--line);border-radius:4px;width:22px;height:22px;cursor:pointer;padding:0}
.lane-tools button:hover{border-color:var(--accent);color:var(--accent)}
.lane-tools button:disabled{opacity:.35;cursor:default}
/* режим «по датам»: колонки по плановым датам окончания, шапка колонки над дорожками; карточки двигаются внутри своей колонки */
.dcol{position:absolute;top:0;border-left:1px solid var(--line);box-sizing:border-box;pointer-events:none}
.dcol:first-of-type{border-left:0}
.dcol.past{background:color-mix(in srgb,var(--p-crit) 5%,transparent)}
.dcol.today{background:color-mix(in srgb,var(--accent) 7%,transparent)}
/* полоса дат над доской: липнет под шапкой страницы, по горизонтали двигается вместе с доской (transform по scrollLeft) */
.dates-bar{position:sticky;top:calc(var(--head-h,0px) + env(safe-area-inset-top,0px));z-index:9;background:var(--bg)}
.dates-in{overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);height:36px;box-sizing:border-box}
.dates-track{position:relative;height:100%}
.dlabel{position:absolute;top:9px;font-size:12.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);font-weight:600;white-space:nowrap}
.dlabel .n{font-weight:400;color:var(--muted);margin-left:6px;font-variant-numeric:tabular-nums}
.dlabel.past{color:var(--p-crit)}
.dlabel.today{color:var(--accent)}
.dlabel.none{color:var(--muted);font-weight:500}
.stage.bydate .wrap{border-top-left-radius:0;border-top-right-radius:0}
.dsplit{position:absolute;top:0;bottom:0;width:11px;margin-left:-5px;cursor:col-resize;z-index:1}
.dsplit::after{content:"";position:absolute;left:5px;top:6px;bottom:6px;width:1px;background:var(--line)}
.dsplit:hover::after,.dsplit.on::after{background:var(--accent);width:3px;left:4px;border-radius:2px}
.dates-in.resizing{cursor:col-resize}
/* горизонтальная прокрутка доски, прилипшая к низу окна: полоса всегда видна, скроллбар нарисован явно (на macOS системный прячется) */
.hscroll{position:sticky;bottom:0;z-index:8;overflow-x:scroll;overflow-y:hidden;height:14px;background:var(--bg);padding-top:2px;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--edge) var(--soft)}
.hscroll>div{height:1px}
.hscroll::-webkit-scrollbar{height:12px}
.hscroll::-webkit-scrollbar-track{background:var(--soft);border-radius:6px}
.hscroll::-webkit-scrollbar-thumb{background:var(--edge);border-radius:6px;border:2px solid var(--soft)}
.hscroll::-webkit-scrollbar-thumb:hover{background:var(--accent)}
/* карточка: текст выделяется, тянуть можно только за рамку (появляется при наведении) и за уголок */
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
.card:hover{border-color:var(--accent)}
.card.out{border-style:dashed;opacity:.7}
.card.crit{box-shadow:0 0 0 2px var(--guide)}
.card.focus{box-shadow:0 0 0 2px var(--accent)}
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
.card .title,.card .state,.card .who,.card .meta,.card .key,.card .sprint-tag{user-select:text;-webkit-user-select:text;cursor:text;touch-action:auto}
.card .key{cursor:pointer}
.card .meta{cursor:default}
.card .grip{position:absolute;background:var(--accent);opacity:0;cursor:grab;z-index:1;transition:opacity .12s}
.card .grip.t{left:0;right:0;top:0;height:8px;border-radius:5px 5px 0 0}
.card .grip.b{left:0;right:14px;bottom:0;height:8px;border-radius:0 0 0 5px}
.card .grip.l{left:0;top:0;bottom:0;width:8px;border-radius:5px 0 0 5px}
.card .grip.r{right:0;top:0;bottom:14px;width:8px;border-radius:0 5px 0 0}
.card:hover .grip,.card.drag .grip{opacity:.85}
.card.drag .grip{cursor:grabbing}
.card .rsz{position:absolute;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 50%,var(--line) 50%);border-bottom-right-radius:5px;z-index:2}
.card:hover .rsz{background:linear-gradient(135deg,transparent 50%,var(--accent) 50%)}
.head{display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
.key{font-family:var(--font-mono);font-size:13.5px;font-weight:500;color:var(--accent);text-decoration:none;flex:0 0 auto}
.sprint-tag{font-size:11px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);color:var(--muted);max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}
.sprint-tag.sel{background:var(--accent);color:var(--accent-fg)}
.sprint-tag.none{background:transparent;border:1px dashed var(--line)}
.title{font-size:14.5px;line-height:19px;min-width:0;flex:1 1 auto;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
.meta{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);white-space:nowrap;flex:0 0 auto}
.kind{display:inline-flex;align-items:center;gap:4px}
.kind i{width:8px;height:8px;border-radius:2px;display:inline-block}
.prio{font-weight:600}
/* состояние задачи (статус, срок, исполнитель) — отдельный блок под чертой, основным цветом: это главное после названия */
.state,.who{font-size:13px;flex:0 0 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)}
.state{display:flex;align-items:center;gap:6px;margin-top:4px;padding-top:7px;border-top:1px solid var(--line)}
.status{font-size:12.5px;line-height:1;padding:4px 7px;border-radius:10px;background:var(--soft);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px;vertical-align:1px}
.who.none{color:var(--muted);font-style:italic}
.who.none::before{background:transparent;border:1px solid var(--muted)}
/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */
.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)}
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.drawer-head b{font-weight:600}
.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px}
.drawer ul{margin:0;padding-left:18px}
.drawer li{margin:3px 0}
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none}
.drawer .ok{color:var(--k-test);margin:8px 0 0}
.drawer .why{color:var(--muted)}
/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */
.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px}
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical}
details{margin-top:12px}
summary{cursor:pointer;color:var(--muted);font-size:14px}
.tbl{max-width:100%;overflow-x:auto;margin-top:8px}
table{border-collapse:collapse;font-size:14px;min-width:520px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500}
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
/* 9. Движение и узкие экраны */
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}}
/* «Открыть карточку» в заголовке карточки (R-1) */
.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px}
.card .open-card:hover,.card .open-card:focus-visible{color:var(--accent);outline:none}
/* слой измерения: карточки шириной CW с высотой по содержимому, невидим */
.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none}
.measure .card{position:static}
.measure .card .title{-webkit-line-clamp:unset}

View File

@ -0,0 +1,88 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { renderWithProviders } from "../../test/render.tsx";
import { computeVisible } from "../analysis/visible.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import { fromHub } from "../model/fromHub.ts";
import { noFilters } from "../types.ts";
import { BoardCanvas, type LayoutStore } from "./BoardCanvas.tsx";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "..", "__fixtures__", name), "utf8");
const { dicts } = dictsFromSettings(settings);
const model = fromHub(JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot, dicts);
const store: LayoutStore = { laneOrder: [], folded: new Set(), colWidths: {}, version: 0 };
function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
const f = { ...noFilters(), ...(over.byDate ? { byDate: true } : {}) };
const vis = computeVisible(model, f, [], "2026-10-14");
const props = { vis, byDate: false, snap: true, today: "2026-10-14", store, sprintSel: new Set<string>(), emptyText: "пусто",
onToggleLane: vi.fn(), onMoveLane: vi.fn(), onColWidth: vi.fn(), ...over };
renderWithProviders(<BoardCanvas {...props} />);
return props;
}
const board = () => document.querySelector(".board") as HTMLElement;
describe("BoardCanvas", () => {
it("дорожки по эпикам и карточки задач с заглушкой", () => {
draw();
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);
expect(lanes).toEqual(["Гудвилл", "Тестовый эпик", "AURORA-9999", "Без эпика"]);
expect(board().querySelectorAll(".card[data-key]")).toHaveLength(9);
});
it("заглушка соседа вне зеркала — на служебной дорожке с подписью", () => {
const vis = computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14");
draw({ vis });
expect([...board().querySelectorAll(".lane-name")].map((e) => e.textContent)).toContain("Нет в зеркале");
expect(within(board()).getByText("нет в зеркале")).toBeInTheDocument();
});
it("ключ ведёт в Jira, ⓘ — в карточку приложения; у заглушки ⓘ нет", () => {
draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Иванов Иван Иванович"]), neighbors: true }, [], "2026-10-14") });
const card = board().querySelector('.card[data-key="AURORA-9001"]') as HTMLElement;
expect(within(card).getByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
expect(within(card).getByRole("link", { name: "Открыть карточку AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001");
const stub = board().querySelector('.card[data-key="AURORA-9500"]') as HTMLElement;
expect(within(stub).queryByRole("link", { name: /Открыть карточку/ })).toBeNull();
});
it("стрелки: по одной на видимую связь, «связанные» пунктиром, блокирующие со стрелкой", () => {
draw();
const paths = [...document.querySelectorAll("svg.edges > path")];
expect(paths).toHaveLength(6);
expect(paths.filter((p) => p.getAttribute("stroke-dasharray") === "5 4")).toHaveLength(1);
expect(paths.filter((p) => p.getAttribute("marker-end"))).toHaveLength(5);
});
it("свёртка и перестановка дорожек — через колбэки", () => {
const p = draw();
const lane = [...board().querySelectorAll(".lane")].find((l) => l.textContent?.includes("Гудвилл")) as HTMLElement;
fireEvent.click(within(lane).getByTitle("Свернуть или развернуть дорожку"));
expect(p.onToggleLane).toHaveBeenCalledWith("Гудвилл");
fireEvent.click(within(lane).getByTitle("Ниже"));
expect(p.onMoveLane).toHaveBeenCalledWith("Гудвилл", 1);
});
it("свёрнутая дорожка прячет свои карточки", () => {
draw({ store: { ...store, folded: new Set(["Гудвилл"]) } });
expect(board().querySelector('.card[data-key="AURORA-9004"]')).toBeNull();
expect(board().querySelector(".lane.folded")).not.toBeNull();
});
it("«по датам»: колонки и подписи дат, двойной щелчок по границе — автоматическая ширина", () => {
const p = draw({ byDate: true });
expect(board().querySelectorAll(".dcol").length).toBeGreaterThan(0);
expect(screen.getByText("16.10")).toBeInTheDocument();
fireEvent.doubleClick(document.querySelector(".dsplit") as HTMLElement);
expect(p.onColWidth).toHaveBeenCalledWith(expect.any(String), null);
});
it("пустая выборка — текст причины", () => {
draw({ vis: computeVisible(model, { ...noFilters(), who: new Set(["Никто"]) }, [], "2026-10-14"), emptyText: "Фильтры не оставили" });
expect(screen.getByText("Фильтры не оставили")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,178 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
import { saveLayout, savedLayout } from "../storage.ts";
import type { Visible } from "../types.ts";
import { Card } from "./Card.tsx";
import { DatesBar } from "./DatesBar.tsx";
import { Edges, Guides } from "./Edges.tsx";
import { Lane } from "./Lane.tsx";
import "../board.css";
export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidths: Record<string, number>; version: number }
export interface BoardCanvasProps {
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
emptyText: string;
onToggleLane(name: string): void;
onMoveLane(name: string, to: number): void;
onColWidth(date: string, w: number | null): void;
children?: ReactNode;
}
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, children }: BoardCanvasProps) {
const boardRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const hscrollRef = useRef<HTMLDivElement>(null);
const datesInRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const [rel, setRel] = useState<Record<string, RelBox>>({});
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
useLayoutEffect(() => {
if (!vis.nodes.length) { setLayout(null); setRel({}); return; }
const els = new Map<string, HTMLElement>();
measureRef.current?.querySelectorAll<HTMLElement>(".card[data-key]").forEach((el) => els.set(el.dataset.key!, el));
const measure = (key: string) => {
const el = els.get(key), oh = el?.offsetHeight ?? 0;
return { h: measuredHeight(oh), other: oh - (el?.querySelector<HTMLElement>(".title")?.offsetHeight ?? 0) };
};
const lay = computeLayout(vis, { byDate, colWidths: store.colWidths, saved: savedLayout(byDate), today, measure });
setLayout(lay);
setRel(lay.rel);
}, [vis, byDate, store.colWidths, store.version, today]);
const placed = useMemo<Placed | null>(() => (layout ? place(layout, rel, store.folded) : null), [layout, rel, store.folded]);
const live = useRef({ placed, rel, layout });
useLayoutEffect(() => { live.current = { placed, rel, layout }; });
// перетаскивание за рамку и растяжка за уголок (строки 753–778): направляющие, сетка, не выше своей дорожки
const onCardDown = useCallback((ev: PointerEvent<HTMLDivElement>, key: string) => {
const t = ev.target as HTMLElement;
if (t.closest("a")) return;
const resizing = !!t.closest(".rsz");
if (!resizing && !t.closest(".grip")) return; // остальное — текст, его можно выделять
const { placed: pl, layout: lay } = live.current;
if (!pl || !lay || !pl.pos[key]) return;
ev.preventDefault();
const el = ev.currentTarget;
el.classList.add("drag");
el.setPointerCapture(ev.pointerId);
const q0 = { ...pl.pos[key] }, start = { x: ev.clientX, y: ev.clientY };
let cur = live.current.rel;
const move = (e: globalThis.PointerEvent) => {
const dx = e.clientX - start.x, dy = e.clientY - start.y;
const others = live.current.placed!.pos, r0 = cur[key];
let next: RelBox;
if (resizing) {
const g = alignEdges(others, key, { x: q0.x, y: q0.y, w: Math.max(MINW, q0.w + dx), h: Math.max(MINH, q0.h + dy) }, true);
let { w, h } = g;
if (snap) { w = Math.round(w / GRID) * GRID; h = Math.round(h / GRID) * GRID; }
const cc = clampToCol(lay.colBounds, key, r0.x, Math.max(MINW, w));
next = { ...r0, w: cc.w, h: Math.max(MINH, h), auto: false };
setGuides({ x: g.guideX, y: g.guideY });
} else {
const g = alignEdges(others, key, { x: Math.max(0, q0.x + dx), y: Math.max(0, q0.y + dy), w: q0.w, h: q0.h }, false);
let { x, y } = g;
if (snap && !g.snappedX) x = Math.round(x / GRID) * GRID;
if (snap && !g.snappedY) y = Math.round(y / GRID) * GRID;
const laneY = live.current.placed!.lanes[lay.laneOf[key]].y;
next = { ...r0, x: clampToCol(lay.colBounds, key, Math.max(0, x), r0.w).x, ry: Math.max(TOP, y - laneY) };
setGuides({ x: g.guideX, y: g.guideY });
}
cur = { ...cur, [key]: next };
setRel(cur);
};
const stop = () => {
el.classList.remove("drag");
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
setGuides(null);
saveLayout(cur, lay.colBounds, byDate);
};
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
}, [snap, byDate]);
// двойной щелчок по уголку: ширина CW (в пределах колонки даты), высота по содержимому (строка 750)
const onAutoSize = useCallback((key: string) => {
const { layout: lay, rel: r } = live.current;
if (!lay) return;
const el = measureRef.current?.querySelector<HTMLElement>(`.card[data-key="${CSS.escape(key)}"]`);
const cc = clampToCol(lay.colBounds, key, r[key].x, CW);
const next = { ...r, [key]: { ...r[key], x: cc.x, w: cc.w, auto: true, h: measuredHeight(el?.offsetHeight ?? 0) } };
setRel(next);
saveLayout(next, lay.colBounds, byDate);
}, [byDate]);
// полоса дат и нижняя полоса прокрутки повторяют горизонтальную прокрутку доски (строки 705–714)
const syncScroll = useCallback(() => {
const w = wrapRef.current;
if (!w) return;
setScroll({ left: w.scrollLeft, wide: w.scrollWidth > w.clientWidth + 1, wrapW: w.offsetWidth, scrollW: w.scrollWidth });
const h = hscrollRef.current;
if (h && Math.abs(h.scrollLeft - w.scrollLeft) > 1) h.scrollLeft = w.scrollLeft;
}, []);
useLayoutEffect(() => {
syncScroll();
const w = wrapRef.current;
if (!w || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(syncScroll);
ro.observe(w);
return () => ro.disconnect();
}, [syncScroll, placed]);
const empty = !vis.nodes.length;
const multiSprint = layout?.multiSprint ?? false;
// слой измерения рисует метку спринта так же, как доска: она влияет на высоту карточки (строка 661)
const measureMulti = new Set(vis.nodes.filter((x) => !x.stub).map((x) => x.sprint)).size > 1;
const boardTop = () => boardRef.current?.getBoundingClientRect().top ?? 0;
const counts = Object.fromEntries(vis.lanes.map((l) => [l.name, l.keys.length]));
const byKey = Object.fromEntries(vis.nodes.map((n) => [n.key, n]));
return (
<>
{byDate && !empty && layout?.dateCols && placed && (
<DatesBar cols={layout.dateCols} width={placed.width} scrollLeft={scroll.left} inRef={datesInRef} onColWidth={onColWidth} />
)}
<div className={"stage" + (byDate && !empty ? " bydate" : "")}>
<div className="wrap" ref={wrapRef} onScroll={syncScroll}>
<div className={"board" + (byDate ? " bydate" : "")} ref={boardRef}
style={placed && !empty ? { width: placed.width, height: placed.height } : { width: "100%", height: "auto" }}>
{empty && <div className="empty">{emptyText}</div>}
{placed && !empty && (
<>
{layout?.dateCols?.map((c) => (
<div key={c.date} className={"dcol" + (c.cls ? " " + c.cls : "")} style={{ left: c.x0, width: c.w, height: placed.height }} />
))}
{placed.lanes.map((g, i) => (
<Lane key={g.name} g={g} index={i} last={placed.lanes.length - 1} count={counts[g.name] ?? 0} lanes={placed.lanes}
boardTop={boardTop} onToggle={onToggleLane} onMove={onMoveLane}
targetClass={dragTarget && dragTarget.to === i ? (i < dragTarget.from ? "target" : i > dragTarget.from ? "target-after" : "") : ""}
onDragTarget={(to) => setDragTarget(to === null ? null : { from: i, to })} />
))}
{Object.entries(placed.pos).map(([k, box]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))}
<Edges links={vis.links} placed={placed} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
</>
)}
</div>
</div>
{children}
</div>
<div className="hscroll" ref={hscrollRef} hidden={!scroll.wide}
style={{ width: scroll.wrapW }}
onScroll={(e) => { const w = wrapRef.current; if (w && Math.abs(e.currentTarget.scrollLeft - w.scrollLeft) > 1) w.scrollLeft = e.currentTarget.scrollLeft; }}>
<div style={{ width: scroll.scrollW, height: 1 }} />
</div>
<div className="measure" ref={measureRef} aria-hidden="true">
{vis.nodes.map((n) => <Card key={n.key} node={n} multiSprint={measureMulti} sprintSel={sprintSel} />)}
</div>
</>
);
}

View File

@ -0,0 +1,50 @@
import { memo, type PointerEvent } from "react";
import { Link } from "react-router";
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
import { fmtDate, shortName, unassigned } from "../model/util.ts";
import type { VisNode } from "../types.ts";
export interface CardProps {
node: VisNode;
box?: Rect; // нет — карточка слоя измерения (ширина CW, высота по содержимому)
rel?: RelBox;
multiSprint: boolean;
sprintSel: Set<string>;
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
onAutoSize?: (key: string) => void;
}
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
return (
<div className={cls} title={n.title} style={style} data-key={n.key}
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
<div className="head">
<a className="key" href={`${JIRA_URL}/browse/${n.key}`} target="_blank" rel="noopener">{n.key}</a>
{!n.stub && <Link className="open-card" to={`/issues/${n.key}`} title="Открыть карточку задачи"
aria-label={`Открыть карточку ${n.key}`}>ⓘ</Link>}
{showTag && (n.sprint
? <span className={"sprint-tag" + (sprintSel.has(n.sprint) ? " sel" : "")} title={n.sprint}>{n.sprint.replace(/^AWPCA_/, "")}</span>
: <span className="sprint-tag none">без спринта</span>)}
</div>
<div className="title" style={{ WebkitLineClamp: box && rel ? lineClamp(rel) : "unset" }}>{n.title}</div>
<div className="meta">
<span className="kind"><i style={{ background: `var(--k-${KIND[n.kind] || "org"})` }} />{n.kind}</span>
<span className="prio" style={{ color: `var(--p-${PRIO[n.prio] || "mid"})` }}>{n.prio}</span>
</div>
{(n.status || n.due) && (
<div className="state">
{n.status && <span className="status" title={n.status}>{n.status}</span>}
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
</div>
)}
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}
<div className="rsz" title="Потянуть — изменить размер; двойной щелчок — размер по содержимому"
onDoubleClick={onAutoSize ? () => onAutoSize(n.key) : undefined} />
<div className="grip t" title="Тянуть за рамку" /><div className="grip b" /><div className="grip l" /><div className="grip r" />
</div>
);
});

View File

@ -0,0 +1,44 @@
import { Fragment, useRef, type PointerEvent, type RefObject } from "react";
import { COLW, GRID } from "../constants.ts";
import { dateText, dateTitle, type DateCol } from "../layout/layout.ts";
// липкая полоса подписей дат над доской: сдвиг — как у горизонтальной прокрутки доски; ручка границы меняет ширину колонки
export function DatesBar({ cols, width, scrollLeft, inRef, onColWidth }: {
cols: DateCol[]; width: number; scrollLeft: number; inRef: RefObject<HTMLDivElement | null>;
onColWidth(date: string, w: number | null): void;
}) {
const raf = useRef(0);
const down = (ev: PointerEvent<HTMLDivElement>, c: DateCol) => {
ev.preventDefault();
const handle = ev.currentTarget, x0 = ev.clientX;
handle.classList.add("on"); inRef.current?.classList.add("resizing");
let last = c.w;
const apply = (w: number) => onColWidth(c.date, w <= COLW ? null : Math.round(w / GRID) * GRID);
const move = (e: globalThis.PointerEvent) => {
last = Math.max(COLW, c.w + e.clientX - x0);
if (!raf.current) raf.current = requestAnimationFrame(() => { raf.current = 0; apply(last); });
};
const stop = () => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop);
handle.classList.remove("on"); inRef.current?.classList.remove("resizing");
apply(last);
};
window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop);
};
const cls = (c: DateCol) => (c.cls ? " " + c.cls : "");
return (
<div className="dates-bar">
<div className="dates-in" ref={inRef}>
<div className="dates-track" style={{ width, transform: `translateX(${-scrollLeft}px)` }}>
{cols.map((c) => (
<Fragment key={c.date}>
<span className={"dlabel" + cls(c)} style={{ left: c.x0 + 8 }} title={dateTitle(c)}>{dateText(c)}<span className="n">{c.count}</span></span>
<div className="dsplit" style={{ left: c.x0 + c.w }} title="Тянуть — ширина колонки; двойной щелчок — автоматическая"
onPointerDown={(e) => down(e, c)} onDoubleClick={() => onColWidth(c.date, null)} />
</Fragment>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import type { Placed } from "../layout/layout.ts";
import { edgePath } from "../layout/edges.ts";
import { RELATED } from "../constants.ts";
import type { VisLink } from "../types.ts";
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
return (
<svg className="edges">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--edge-strong)" />
</marker>
<marker id="arrHand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--guide)" />
</marker>
</defs>
{links.map((l) => {
const a = placed.pos[l.src], b = placed.pos[l.dst];
if (!a || !b) return null;
const related = l.type === RELATED;
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
strokeDasharray={related ? "5 4" : undefined}
markerEnd={related ? undefined : l.hand ? "url(#arrHand)" : "url(#arr)"} />;
})}
</svg>
);
}
export function Guides({ x, y, width, height }: { x: number | null; y: number | null; width: number; height: number }) {
return (
<svg className="guides">
{x !== null && <line x1={x} y1={0} x2={x} y2={height} />}
{y !== null && <line x1={0} y1={y} x2={width} y2={y} />}
</svg>
);
}

View File

@ -0,0 +1,52 @@
import { useRef, useState, type PointerEvent } from "react";
import type { LaneGeom } from "../layout/layout.ts";
import { plural } from "../model/util.ts";
interface LaneProps {
g: LaneGeom; index: number; last: number; count: number;
lanes: LaneGeom[]; // все дорожки — для выбора места при перетаскивании
boardTop: () => number; // верх доски в координатах окна
targetClass: string; // "target" / "target-after" — подсветка целевой границы (строка 729)
onToggle(name: string): void;
onMove(name: string, to: number): void;
onDragTarget(to: number | null): void; // цель перетаскивания этой дорожки; null — перетаскивание закончено
}
export function Lane({ g, index, last, count, lanes, boardTop, targetClass, onToggle, onMove, onDragTarget }: LaneProps) {
const [dragging, setDragging] = useState(false);
const nameRef = useRef<HTMLSpanElement>(null);
const where = (clientY: number) => {
const y = clientY - boardTop();
const i = lanes.findIndex((x) => y < x.y + x.h / 2);
return i < 0 ? lanes.length - 1 : i;
};
const down = (ev: PointerEvent<HTMLSpanElement>) => {
ev.preventDefault();
const el = nameRef.current!;
el.setPointerCapture(ev.pointerId);
let target = index;
setDragging(true);
const move = (e: globalThis.PointerEvent) => { target = where(e.clientY); onDragTarget(target); };
const stop = () => {
el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", stop); el.removeEventListener("pointercancel", stop);
setDragging(false);
onDragTarget(null);
if (target !== index) onMove(g.name, target);
};
el.addEventListener("pointermove", move); el.addEventListener("pointerup", stop); el.addEventListener("pointercancel", stop);
};
const cls = "lane" + (g.folded ? " folded" : "") + (dragging ? " dragging" : "") + (targetClass ? " " + targetClass : "");
return (
<div className={cls} style={{ top: g.y, height: g.h }}>
<div className="lane-head">
<button type="button" className="fold" title="Свернуть или развернуть дорожку" onClick={() => onToggle(g.name)} />
<span ref={nameRef} className="lane-name" title="Перетащить, чтобы поменять дорожки местами" onPointerDown={down}>{g.name}</span>
<span className="lane-count">{plural(count, "задача", "задачи", "задач")}</span>
<span className="lane-tools">
<button type="button" title="Выше" disabled={index === 0} onClick={() => onMove(g.name, index - 1)}>▲</button>
<button type="button" title="Ниже" disabled={index === last} onClick={() => onMove(g.name, index + 1)}>▼</button>
</span>
</div>
</div>
);
}