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:
EENE Dashboard
2026-06-08 22:09:46 +09:00
parent 525a4fc1f2
commit cf72281c6d
28 changed files with 4743 additions and 314 deletions

View 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 };
}