feat: quarter board theme, hub column, and team panel UX
Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
234
frontend/src/hooks/useBoardConnectors.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function relBox(el: Element, parent: Element): Box {
|
||||
const r = el.getBoundingClientRect();
|
||||
const p = parent.getBoundingClientRect();
|
||||
return {
|
||||
left: r.left - p.left,
|
||||
top: r.top - p.top,
|
||||
right: r.right - p.left,
|
||||
bottom: r.bottom - p.top,
|
||||
cx: r.left - p.left + r.width / 2,
|
||||
cy: r.top - p.top + r.height / 2,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(n: number) {
|
||||
return Math.round(n * 2) / 2;
|
||||
}
|
||||
|
||||
function pointsToPath(points: Point[]) {
|
||||
return points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${snap(p.x).toFixed(1)} ${snap(p.y).toFixed(1)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function diamondVertex(cx: number, cy: number, size: number, vertex: 'top' | 'right' | 'bottom' | 'left'): Point {
|
||||
const r = size / Math.SQRT2;
|
||||
if (vertex === 'top') return { x: cx, y: cy - r };
|
||||
if (vertex === 'right') return { x: cx + r, y: cy };
|
||||
if (vertex === 'bottom') return { x: cx, y: cy + r };
|
||||
return { x: cx - r, y: cy };
|
||||
}
|
||||
|
||||
function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string): Point {
|
||||
const d = size / (2 * Math.SQRT2);
|
||||
if (edge === 'top-left') return { x: cx - d, y: cy - d };
|
||||
if (edge === 'top-right') return { x: cx + d, y: cy - d };
|
||||
if (edge === 'bottom-left') return { x: cx - d, y: cy + d };
|
||||
return { x: cx + d, y: cy + d };
|
||||
}
|
||||
|
||||
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
|
||||
const cardBox = relBox(cardEl, layout);
|
||||
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
|
||||
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
|
||||
}
|
||||
|
||||
const FACE_LINKS = [
|
||||
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
|
||||
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
|
||||
{ edge: 'bottom-left', card: '.dept-card--ex', side: 'right' as const, vert: 'bottom' as const, knee: 'left' as const },
|
||||
{ edge: 'bottom-right', card: '.dept-card--ga', side: 'left' as const, vert: 'bottom' as const, knee: 'right' as const },
|
||||
];
|
||||
|
||||
function buildBentPath(
|
||||
cardAnchor: Point,
|
||||
edgeMid: Point,
|
||||
side: 'left' | 'right',
|
||||
kneeX: number,
|
||||
approachX: number,
|
||||
): Point[] {
|
||||
const minLeg = 18;
|
||||
let x1 = kneeX;
|
||||
if (side === 'right') {
|
||||
if (x1 < cardAnchor.x + minLeg) x1 = cardAnchor.x + minLeg;
|
||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||
}
|
||||
if (x1 > cardAnchor.x - minLeg) x1 = cardAnchor.x - minLeg;
|
||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||
}
|
||||
|
||||
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
|
||||
return vert === 'top' ? faceY + runX : faceY - runX;
|
||||
}
|
||||
|
||||
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
|
||||
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
||||
const wrap = diamond.parentElement;
|
||||
if (!wrap) return;
|
||||
const rowW = wrap.clientWidth;
|
||||
const rowH = wrap.clientHeight;
|
||||
const fitInRow = 1.04 / Math.SQRT2;
|
||||
let size = Math.min(rowW * fitInRow, rowH * fitInRow);
|
||||
let scale = 0.9;
|
||||
const v = getComputedStyle(hubColumn).getPropertyValue('--hub-diamond-scale').trim();
|
||||
if (v) scale = parseFloat(v) || scale;
|
||||
size = Math.max(Math.floor(size * scale), Math.floor(150 * scale));
|
||||
diamond.style.width = `${size}px`;
|
||||
diamond.style.height = `${size}px`;
|
||||
}
|
||||
|
||||
export function useBoardConnectors(enabled = true) {
|
||||
const lineGroupRef = useRef<SVGGElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const drawConnectors = () => {
|
||||
const layout = document.querySelector('.board-layout');
|
||||
const diamond = document.getElementById('hub-diamond');
|
||||
const hubColumn = document.getElementById('hub-column');
|
||||
const lineGroup = lineGroupRef.current;
|
||||
const svg = svgRef.current;
|
||||
if (!layout || !diamond || !lineGroup || !svg) return;
|
||||
|
||||
fitDiamond(hubColumn, diamond);
|
||||
|
||||
if (window.innerWidth <= 1200) {
|
||||
lineGroup.innerHTML = '';
|
||||
svg.removeAttribute('viewBox');
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutBox = layout.getBoundingClientRect();
|
||||
const diamondBox = relBox(diamond, layout);
|
||||
const diamondSize = diamond.offsetWidth;
|
||||
const { cx, cy } = diamondBox;
|
||||
|
||||
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||
lineGroup.innerHTML = '';
|
||||
|
||||
const topV = diamondVertex(cx, cy, diamondSize, 'top');
|
||||
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
|
||||
|
||||
const msgBox = document.querySelector('.hub-postit-sheet--front');
|
||||
const focusBox = document.querySelector('.hub-schedule-planner');
|
||||
|
||||
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
|
||||
const hrmBox = document.querySelector('.dept-card--hrm');
|
||||
const exBox = document.querySelector('.dept-card--ex');
|
||||
const hrdBox = document.querySelector('.dept-card--hrd');
|
||||
const gaBox = document.querySelector('.dept-card--ga');
|
||||
const kneeXs = { left: 0, right: 0 };
|
||||
|
||||
if (hubBox && hrmBox && exBox) {
|
||||
const innerLeft = Math.max(relBox(hrmBox, layout).right, relBox(exBox, layout).right);
|
||||
kneeXs.left = (innerLeft + hubBox.left) / 2;
|
||||
}
|
||||
if (hubBox && hrdBox && gaBox) {
|
||||
const innerRight = Math.min(relBox(hrdBox, layout).left, relBox(gaBox, layout).left);
|
||||
kneeXs.right = (innerRight + hubBox.right) / 2;
|
||||
}
|
||||
|
||||
const d = diamondSize / (2 * Math.SQRT2);
|
||||
const approachGap = 32;
|
||||
const leftApproachX = cx - d - approachGap;
|
||||
const rightApproachX = cx + d + approachGap;
|
||||
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
|
||||
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
|
||||
|
||||
const appendPath = (points: Point[]) => {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', pointsToPath(points));
|
||||
path.setAttribute('stroke', '#b0bcc8');
|
||||
path.setAttribute('stroke-width', '2.5');
|
||||
path.setAttribute('opacity', '0.85');
|
||||
path.setAttribute('fill', 'none');
|
||||
lineGroup.appendChild(path);
|
||||
};
|
||||
|
||||
FACE_LINKS.forEach((link) => {
|
||||
const cardEl = document.querySelector(link.card);
|
||||
if (!cardEl) return;
|
||||
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
|
||||
const runX = link.knee === 'left' ? leftRunX : rightRunX;
|
||||
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
|
||||
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
|
||||
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
|
||||
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
|
||||
});
|
||||
|
||||
if (msgBox) {
|
||||
const msg = relBox(msgBox, layout);
|
||||
appendPath([topV, { x: cx, y: msg.bottom }]);
|
||||
}
|
||||
if (focusBox) {
|
||||
const focus = relBox(focusBox, layout);
|
||||
appendPath([bottomV, { x: cx, y: focus.top }]);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDraw = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(drawConnectors, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
drawConnectors();
|
||||
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.ready.then(() => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(drawConnectors));
|
||||
});
|
||||
}
|
||||
|
||||
const layoutEl = document.querySelector('.board-layout');
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (layoutEl && typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(scheduleDraw);
|
||||
ro.observe(layoutEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', scheduleDraw);
|
||||
clearTimeout(resizeTimer);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { svgRef, lineGroupRef };
|
||||
}
|
||||
Reference in New Issue
Block a user