EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -34,7 +34,98 @@ function relBox(el: Element, parent: Element): Box {
|
||||
}
|
||||
|
||||
function snap(n: number) {
|
||||
return Math.round(n * 2) / 2;
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/** 참고 레이아웃: 카드 변 부메랑(둔각 다이아) 끝점 */
|
||||
function appendBoomerangMarker(
|
||||
group: SVGGElement,
|
||||
at: Point,
|
||||
side: 'left' | 'right',
|
||||
fill: string,
|
||||
) {
|
||||
const dir = side === 'right' ? 1 : -1;
|
||||
const tipLen = 7;
|
||||
const wing = 4.5;
|
||||
const pts = [
|
||||
[at.x + dir * tipLen, at.y],
|
||||
[at.x + dir * 1.5, at.y - wing],
|
||||
[at.x - dir * 2.5, at.y],
|
||||
[at.x + dir * 1.5, at.y + wing],
|
||||
]
|
||||
.map(([x, y]) => `${snap(x)},${snap(y)}`)
|
||||
.join(' ');
|
||||
const poly = document.createElementNS(SVG_NS, 'polygon');
|
||||
poly.setAttribute('points', pts);
|
||||
poly.setAttribute('fill', fill);
|
||||
group.appendChild(poly);
|
||||
}
|
||||
|
||||
/** 참고 레이아웃: 허브 박스 연결부 V형 부메랑 (박스 안쪽 방향) */
|
||||
function appendHubBoomerangMarker(
|
||||
group: SVGGElement,
|
||||
at: Point,
|
||||
dir: 'up' | 'down',
|
||||
fill: string,
|
||||
) {
|
||||
const sign = dir === 'down' ? 1 : -1;
|
||||
const tipLen = 5.5;
|
||||
const wing = 5;
|
||||
const base = 3;
|
||||
const pts = [
|
||||
[at.x, at.y + sign * tipLen],
|
||||
[at.x - wing, at.y - sign * base],
|
||||
[at.x + wing, at.y - sign * base],
|
||||
]
|
||||
.map(([x, y]) => `${snap(x)},${snap(y)}`)
|
||||
.join(' ');
|
||||
const poly = document.createElementNS(SVG_NS, 'polygon');
|
||||
poly.setAttribute('points', pts);
|
||||
poly.setAttribute('fill', fill);
|
||||
group.appendChild(poly);
|
||||
}
|
||||
|
||||
function pathLength(a: Point, b: Point) {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/** snap·배율 100%에서도 0px 세그먼트가 되지 않도록 보정 */
|
||||
function normalizePathPoints(points: Point[], minSeg = 1.25): Point[] {
|
||||
if (points.length < 2) return points;
|
||||
const out: Point[] = [{ ...points[0] }];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = out[out.length - 1];
|
||||
const cur = { ...points[i] };
|
||||
const len = pathLength(prev, cur);
|
||||
if (len < minSeg) {
|
||||
const ref =
|
||||
i + 1 < points.length
|
||||
? points[i + 1]
|
||||
: i > 0
|
||||
? points[i - 1]
|
||||
: cur;
|
||||
let dx = cur.x - prev.x;
|
||||
let dy = cur.y - prev.y;
|
||||
if (len < 0.01) {
|
||||
dx = ref.x - prev.x;
|
||||
dy = ref.y - prev.y;
|
||||
}
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
out.push({ x: prev.x + (dx / d) * minSeg, y: prev.y + (dy / d) * minSeg });
|
||||
} else {
|
||||
out.push(cur);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** reference 상·하: 허브 테두리 ↔ 다이아 꼭짓점 (wrap clamp·stub 보정 없음) */
|
||||
function referenceVerticalLine(cx: number, yHub: number, yDiamond: number): Point[] {
|
||||
if (Math.abs(yDiamond - yHub) < 0.5) return [];
|
||||
return [
|
||||
{ x: cx, y: yHub },
|
||||
{ x: cx, y: yDiamond },
|
||||
];
|
||||
}
|
||||
|
||||
function pointsToPath(points: Point[]) {
|
||||
@@ -59,12 +150,42 @@ function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string)
|
||||
return { x: cx + d, y: cy + d };
|
||||
}
|
||||
|
||||
/** 다이아몬드 변 중점에서 바깥(테두리)쪽으로 살짝 밀어 연결 */
|
||||
function diamondBorderEdgeMidpoint(
|
||||
cx: number,
|
||||
cy: number,
|
||||
size: number,
|
||||
edge: string,
|
||||
outward = 2,
|
||||
): Point {
|
||||
const mid = diamondEdgeMidpoint(cx, cy, size, edge);
|
||||
const dx = mid.x - cx;
|
||||
const dy = mid.y - cy;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { x: mid.x + (dx / len) * outward, y: mid.y + (dy / len) * outward };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 참고 레이아웃: 카드 안쪽 변 — 위 행은 중앙보다 살짝 아래, 아래 행은 살짝 위 */
|
||||
function cardReferenceInnerEdgeAnchor(
|
||||
cardEl: Element,
|
||||
layout: Element,
|
||||
side: 'left' | 'right',
|
||||
vert: 'top' | 'bottom',
|
||||
): Point {
|
||||
const cardBox = relBox(cardEl, layout);
|
||||
const offset = Math.min(36, Math.max(22, cardBox.height * 0.09));
|
||||
const yShift = vert === 'top' ? offset : -offset;
|
||||
const pad = 14;
|
||||
const y = Math.max(cardBox.top + pad, Math.min(cardBox.bottom - pad, cardBox.cy + yShift));
|
||||
return { x: side === 'right' ? cardBox.right : cardBox.left, y };
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -89,10 +210,60 @@ function buildBentPath(
|
||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
||||
}
|
||||
|
||||
/** 참고 레이아웃: 카드 → 짧은 수평 → ~120° 둔각 꺾임 → 대각 → 다이아몬드 */
|
||||
function buildReferenceElbowPath(
|
||||
cardAnchor: Point,
|
||||
edgeMid: Point,
|
||||
side: 'left' | 'right',
|
||||
vert: 'top' | 'bottom',
|
||||
): Point[] {
|
||||
const towardCenter = side === 'right' ? 1 : -1;
|
||||
const turnSign = vert === 'top' ? -1 : 1;
|
||||
const turn = Math.PI / 3; // 60° 꺾임 → 내각 120°
|
||||
const uOutX = towardCenter * Math.cos(turn);
|
||||
const uOutY = turnSign * Math.sin(turn);
|
||||
|
||||
const minStub = 18;
|
||||
const maxStub = 64;
|
||||
|
||||
let knee: Point;
|
||||
if (Math.abs(uOutY) > 1e-4) {
|
||||
const t = (cardAnchor.y - edgeMid.y) / uOutY;
|
||||
knee = { x: edgeMid.x - t * uOutX, y: cardAnchor.y };
|
||||
} else {
|
||||
knee = { x: cardAnchor.x + towardCenter * minStub, y: cardAnchor.y };
|
||||
}
|
||||
|
||||
const stub = (knee.x - cardAnchor.x) * towardCenter;
|
||||
if (!Number.isFinite(stub) || stub < minStub || stub > maxStub) {
|
||||
knee = { x: cardAnchor.x + towardCenter * Math.max(minStub, Math.min(maxStub, stub || minStub)), y: cardAnchor.y };
|
||||
}
|
||||
|
||||
return [cardAnchor, knee, edgeMid];
|
||||
}
|
||||
|
||||
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
|
||||
return vert === 'top' ? faceY + runX : faceY - runX;
|
||||
}
|
||||
|
||||
const REF_HUB_LINE_COLOR = '#c5d2de';
|
||||
const REF_HUB_DOT_COLOR = '#b8c6d4';
|
||||
/** 둥근 꼭짓점 — 선 끝은 테두리 밖(다이아 레이어에 가려짐) */
|
||||
const REF_DIAMOND_LINE_OVERLAP = 0;
|
||||
|
||||
/** reference 상·하: 실제 렌더 박스 + border-radius 보정 (기하 topV는 둥근 꼭짓점보다 바깥) */
|
||||
function referenceDiamondVerticalY(
|
||||
diamondEl: HTMLElement,
|
||||
diamondBox: Box,
|
||||
end: 'top' | 'bottom',
|
||||
): number {
|
||||
const r = parseFloat(getComputedStyle(diamondEl).borderRadius) || 16;
|
||||
const tipOffset = Math.min(r * 0.7, diamondBox.height * 0.07);
|
||||
return end === 'top'
|
||||
? diamondBox.top + tipOffset + REF_DIAMOND_LINE_OVERLAP
|
||||
: diamondBox.bottom - tipOffset - REF_DIAMOND_LINE_OVERLAP;
|
||||
}
|
||||
|
||||
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
|
||||
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
||||
const wrap = diamond.parentElement;
|
||||
@@ -109,9 +280,13 @@ function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null)
|
||||
diamond.style.height = `${size}px`;
|
||||
}
|
||||
|
||||
export function useBoardConnectors(enabled = true) {
|
||||
export type ConnectorStyle = 'default' | 'reference';
|
||||
|
||||
export function useBoardConnectors(enabled = true, style: ConnectorStyle = 'default') {
|
||||
const lineGroupRef = useRef<SVGGElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const dotGroupRef = useRef<SVGGElement>(null);
|
||||
const dotSvgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -124,29 +299,73 @@ export function useBoardConnectors(enabled = true) {
|
||||
const hubColumn = document.getElementById('hub-column');
|
||||
const lineGroup = lineGroupRef.current;
|
||||
const svg = svgRef.current;
|
||||
const dotGroup = dotGroupRef.current;
|
||||
const dotSvg = dotSvgRef.current;
|
||||
if (!layout || !diamond || !lineGroup || !svg) return;
|
||||
|
||||
fitDiamond(hubColumn, diamond);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!layout.isConnected || !diamond.isConnected) return;
|
||||
drawConnectorsNow(
|
||||
layout,
|
||||
diamond,
|
||||
hubColumn,
|
||||
lineGroup,
|
||||
svg,
|
||||
dotGroup,
|
||||
dotSvg,
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const drawConnectorsNow = (
|
||||
layout: Element,
|
||||
diamond: HTMLElement,
|
||||
hubColumn: HTMLElement | null,
|
||||
lineGroup: SVGGElement,
|
||||
svg: SVGSVGElement,
|
||||
dotGroup: SVGGElement | null,
|
||||
dotSvg: SVGSVGElement | null,
|
||||
) => {
|
||||
if (window.innerWidth <= 1200) {
|
||||
lineGroup.innerHTML = '';
|
||||
dotGroup && (dotGroup.innerHTML = '');
|
||||
svg.removeAttribute('viewBox');
|
||||
dotSvg?.removeAttribute('viewBox');
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutBox = layout.getBoundingClientRect();
|
||||
const diamondBox = relBox(diamond, layout);
|
||||
const diamondSize = diamond.offsetWidth;
|
||||
const { cx, cy } = diamondBox;
|
||||
const diamondCx = diamondBox.cx;
|
||||
const diamondCy = diamondBox.cy;
|
||||
|
||||
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||
lineGroup.innerHTML = '';
|
||||
if (dotGroup) dotGroup.innerHTML = '';
|
||||
if (dotSvg) dotSvg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||
|
||||
const topV = diamondVertex(cx, cy, diamondSize, 'top');
|
||||
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
|
||||
const topV = diamondVertex(diamondCx, diamondCy, diamondSize, 'top');
|
||||
const bottomV = diamondVertex(diamondCx, diamondCy, diamondSize, 'bottom');
|
||||
|
||||
const msgBox = document.querySelector('.hub-postit-sheet--front');
|
||||
const focusBox = document.querySelector('.hub-schedule-planner');
|
||||
const hubColumnEl = layout.querySelector('.hub-column, #hub-column');
|
||||
const hubBoxEls = hubColumnEl
|
||||
? Array.from(hubColumnEl.querySelectorAll(':scope > .hub-box'))
|
||||
: [];
|
||||
const topHubBox =
|
||||
hubBoxEls[0] ??
|
||||
(style === 'reference'
|
||||
? layout.querySelector('.hub-box--message')
|
||||
: layout.querySelector('.hub-postit-sheet--front'));
|
||||
const bottomHubBox =
|
||||
(hubBoxEls.length > 1 ? hubBoxEls[hubBoxEls.length - 1] : hubBoxEls[0]) ??
|
||||
(style === 'reference'
|
||||
? layout.querySelector('.hub-box--focus')
|
||||
: layout.querySelector('.hub-schedule-planner'));
|
||||
|
||||
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
|
||||
const hrmBox = document.querySelector('.dept-card--hrm');
|
||||
@@ -166,39 +385,138 @@ export function useBoardConnectors(enabled = true) {
|
||||
|
||||
const d = diamondSize / (2 * Math.SQRT2);
|
||||
const approachGap = 32;
|
||||
const leftApproachX = cx - d - approachGap;
|
||||
const rightApproachX = cx + d + approachGap;
|
||||
const leftApproachX = diamondCx - d - approachGap;
|
||||
const rightApproachX = diamondCx + 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);
|
||||
const REF_CARD_LINE_COLORS: Record<string, string> = {
|
||||
'.dept-card--hrm': '#4a90d9',
|
||||
'.dept-card--hrd': '#37a184',
|
||||
'.dept-card--ex': '#9168b8',
|
||||
'.dept-card--ga': '#2563ab',
|
||||
};
|
||||
|
||||
const appendPath = (
|
||||
points: Point[],
|
||||
opts?: {
|
||||
cardSelector?: string;
|
||||
dotAt?: Point;
|
||||
vertical?: boolean;
|
||||
markerSide?: 'left' | 'right';
|
||||
markerDir?: 'up' | 'down';
|
||||
},
|
||||
) => {
|
||||
if (points.length < 2) return;
|
||||
|
||||
const cardSelector = opts?.cardSelector;
|
||||
const stroke =
|
||||
opts?.vertical
|
||||
? REF_HUB_LINE_COLOR
|
||||
: style === 'reference' && cardSelector
|
||||
? REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_LINE_COLOR
|
||||
: style === 'reference'
|
||||
? REF_HUB_LINE_COLOR
|
||||
: '#b0bcc8';
|
||||
|
||||
if (opts?.vertical && points.length === 2) {
|
||||
const [a, b] = points;
|
||||
const line = document.createElementNS(SVG_NS, 'line');
|
||||
line.setAttribute('x1', String(snap(a.x)));
|
||||
line.setAttribute('y1', String(snap(a.y)));
|
||||
line.setAttribute('x2', String(snap(b.x)));
|
||||
line.setAttribute('y2', String(snap(b.y)));
|
||||
line.setAttribute('stroke', stroke);
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('stroke-linecap', 'butt');
|
||||
lineGroup.appendChild(line);
|
||||
} else {
|
||||
const path = document.createElementNS(SVG_NS, 'path');
|
||||
const normalized = normalizePathPoints(points);
|
||||
if (normalized.length < 2) return;
|
||||
path.setAttribute('d', pointsToPath(normalized));
|
||||
path.setAttribute('stroke', stroke);
|
||||
path.setAttribute('stroke-width', style === 'reference' ? '2' : '2.5');
|
||||
path.setAttribute('opacity', style === 'reference' ? '1' : '0.85');
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke-linecap', style === 'reference' ? 'butt' : 'round');
|
||||
path.setAttribute('stroke-linejoin', style === 'reference' ? 'miter' : 'round');
|
||||
lineGroup.appendChild(path);
|
||||
}
|
||||
|
||||
if (style === 'reference' && opts?.dotAt && dotGroup) {
|
||||
const fill =
|
||||
cardSelector ? (REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_DOT_COLOR) : REF_HUB_DOT_COLOR;
|
||||
if (cardSelector && opts.markerSide) {
|
||||
appendBoomerangMarker(dotGroup, opts.dotAt, opts.markerSide, fill);
|
||||
} else if (opts.markerDir) {
|
||||
appendHubBoomerangMarker(dotGroup, opts.dotAt, opts.markerDir, fill);
|
||||
} else {
|
||||
const dot = document.createElementNS(SVG_NS, 'circle');
|
||||
dot.setAttribute('cx', String(snap(opts.dotAt.x)));
|
||||
dot.setAttribute('cy', String(snap(opts.dotAt.y)));
|
||||
dot.setAttribute('r', '4');
|
||||
dot.setAttribute('fill', fill);
|
||||
dotGroup.appendChild(dot);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
FACE_LINKS.forEach((link) => {
|
||||
const cardEl = document.querySelector(link.card);
|
||||
if (!cardEl) return;
|
||||
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
|
||||
const edgeMid =
|
||||
style === 'reference'
|
||||
? diamondBorderEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge)
|
||||
: diamondEdgeMidpoint(diamondCx, diamondCy, 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));
|
||||
const cardAnchor =
|
||||
style === 'reference'
|
||||
? cardReferenceInnerEdgeAnchor(cardEl, layout, link.side, link.vert)
|
||||
: cardEdgeAnchor(
|
||||
cardEl,
|
||||
layout,
|
||||
link.side,
|
||||
anchorYForSymmetricFace(edgeMid.y, runX, link.vert),
|
||||
);
|
||||
const pathPoints =
|
||||
style === 'reference'
|
||||
? buildReferenceElbowPath(cardAnchor, edgeMid, link.side, link.vert)
|
||||
: buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX);
|
||||
|
||||
if (style === 'reference') {
|
||||
appendPath(pathPoints, {
|
||||
cardSelector: link.card,
|
||||
dotAt: cardAnchor,
|
||||
markerSide: link.side,
|
||||
});
|
||||
} else {
|
||||
appendPath(pathPoints);
|
||||
}
|
||||
});
|
||||
|
||||
if (msgBox) {
|
||||
const msg = relBox(msgBox, layout);
|
||||
appendPath([topV, { x: cx, y: msg.bottom }]);
|
||||
if (style === 'reference' && topHubBox) {
|
||||
const topHub = relBox(topHubBox, layout);
|
||||
const hubAnchor = { x: diamondCx, y: topHub.bottom };
|
||||
const diamondTopY = referenceDiamondVerticalY(diamond, diamondBox, 'top');
|
||||
const topPoints = referenceVerticalLine(diamondCx, hubAnchor.y, diamondTopY);
|
||||
appendPath(topPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'down' });
|
||||
} else if (topHubBox) {
|
||||
const topHub = relBox(topHubBox, layout);
|
||||
appendPath([topV, { x: diamondCx, y: topHub.bottom }]);
|
||||
}
|
||||
if (focusBox) {
|
||||
const focus = relBox(focusBox, layout);
|
||||
appendPath([bottomV, { x: cx, y: focus.top }]);
|
||||
|
||||
if (style === 'reference' && bottomHubBox) {
|
||||
const bottomHub = relBox(bottomHubBox, layout);
|
||||
const hubAnchor = { x: diamondCx, y: bottomHub.top };
|
||||
const diamondBottomY = referenceDiamondVerticalY(diamond, diamondBox, 'bottom');
|
||||
const bottomPoints = referenceVerticalLine(diamondCx, diamondBottomY, hubAnchor.y);
|
||||
appendPath(bottomPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'up' });
|
||||
} else if (bottomHubBox) {
|
||||
const bottomHub = relBox(bottomHubBox, layout);
|
||||
appendPath([bottomV, { x: diamondCx, y: bottomHub.top }]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,10 +535,12 @@ export function useBoardConnectors(enabled = true) {
|
||||
}
|
||||
|
||||
const layoutEl = document.querySelector('.board-layout');
|
||||
const hubColEl = document.getElementById('hub-column');
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (layoutEl && typeof ResizeObserver !== 'undefined') {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(scheduleDraw);
|
||||
ro.observe(layoutEl);
|
||||
if (layoutEl) ro.observe(layoutEl);
|
||||
if (hubColEl) ro.observe(hubColEl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -228,7 +548,7 @@ export function useBoardConnectors(enabled = true) {
|
||||
clearTimeout(resizeTimer);
|
||||
ro?.disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, style]);
|
||||
|
||||
return { svgRef, lineGroupRef };
|
||||
return { svgRef, lineGroupRef, dotSvgRef, dotGroupRef };
|
||||
}
|
||||
|
||||
42
frontend/src/hooks/useBoardReferenceDate.ts
Normal file
42
frontend/src/hooks/useBoardReferenceDate.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
BOARD_REF_DATE_KEY,
|
||||
dateToQuarter,
|
||||
parseIsoDate,
|
||||
startOfDay,
|
||||
toIsoDate,
|
||||
} from '../lib/boardCalendar';
|
||||
|
||||
export function useBoardReferenceDate() {
|
||||
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseIsoDate(stored);
|
||||
if (parsed) return startOfDay(parsed);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return startOfDay(new Date());
|
||||
});
|
||||
|
||||
const setReferenceDate = useCallback((d: Date) => {
|
||||
const normalized = startOfDay(d);
|
||||
setReferenceDateState(normalized);
|
||||
try {
|
||||
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
|
||||
|
||||
const resetToToday = useCallback(() => {
|
||||
setReferenceDate(startOfDay(new Date()));
|
||||
}, [setReferenceDate]);
|
||||
|
||||
return { referenceDate, setReferenceDate, quarter, resetToToday };
|
||||
}
|
||||
92
frontend/src/hooks/useFileBuffer.ts
Normal file
92
frontend/src/hooks/useFileBuffer.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fileViewUrl } from '../lib/apiClient';
|
||||
|
||||
export function useFileArrayBuffer(fileId: string | null) {
|
||||
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) {
|
||||
setBuffer(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setBuffer(null);
|
||||
|
||||
fetch(fileViewUrl(fileId))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setBuffer(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : '미리보기 실패');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileId]);
|
||||
|
||||
return { buffer, loading, error };
|
||||
}
|
||||
|
||||
export function useFileBlobUrl(fileId: string | null, mime: string) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) {
|
||||
setBlobUrl(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
fetch(fileViewUrl(fileId))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(new Blob([data], { type: mime }));
|
||||
setBlobUrl(objectUrl);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : '미리보기 실패');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [fileId, mime]);
|
||||
|
||||
return { blobUrl, loading, error };
|
||||
}
|
||||
58
frontend/src/hooks/useRoutineCategoryMilestones.ts
Normal file
58
frontend/src/hooks/useRoutineCategoryMilestones.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
import {
|
||||
ROUTINE_CATEGORIES,
|
||||
pickRoutineCategoryTask,
|
||||
type RoutineCategory,
|
||||
} from '../lib/routineCategories';
|
||||
import type { Milestone, Task } from '../types';
|
||||
|
||||
type TaskWithMilestones = Task & { milestones?: Milestone[] };
|
||||
|
||||
export interface RoutineFocusMilestone {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface RoutineCategoryFocus {
|
||||
category: RoutineCategory;
|
||||
task: Task | null;
|
||||
milestones: RoutineFocusMilestone[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useRoutineCategoryMilestones(routineTasks: Task[]): RoutineCategoryFocus[] {
|
||||
const shells = ROUTINE_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
task: pickRoutineCategoryTask(routineTasks, category),
|
||||
}));
|
||||
|
||||
const queries = useQueries({
|
||||
queries: shells.map(({ task }) => ({
|
||||
queryKey: ['task', task?.id, 'hub-routine-focus'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<TaskWithMilestones>(`/tasks/${task!.id}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!task?.id,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
});
|
||||
|
||||
return shells.map(({ category, task }, index) => {
|
||||
const data = queries[index].data;
|
||||
const milestones = (data?.milestones ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((m) => ({ id: m.id, title: m.title.trim() }))
|
||||
.filter((m) => m.title);
|
||||
|
||||
return {
|
||||
category,
|
||||
task,
|
||||
milestones,
|
||||
isLoading: queries[index].isLoading && !!task?.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user