feat(graph): compact kanban columns + column headers
- Empty columns no longer reserve space — only non-empty columns render - Active columns are packed tightly next to each other - Each column gets a header label (Todo, In Progress, Done, Review, Approved) with status color and subtle underline - Columns centered under owner node
This commit is contained in:
parent
6866f003dc
commit
36336cbd06
3 changed files with 105 additions and 26 deletions
|
|
@ -8,6 +8,7 @@ import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/co
|
|||
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
|
||||
import { truncateText } from './draw-misc';
|
||||
import { hexWithAlpha } from './render-cache';
|
||||
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
|
||||
|
||||
/**
|
||||
* Draw all task nodes as pill-shaped cards.
|
||||
|
|
@ -176,3 +177,30 @@ function drawTaskPill(
|
|||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw kanban column headers above task columns.
|
||||
*/
|
||||
export function drawColumnHeaders(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
zones: KanbanZoneInfo[],
|
||||
): void {
|
||||
for (const zone of zones) {
|
||||
for (const header of zone.headers) {
|
||||
ctx.font = 'bold 8px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, 0.6);
|
||||
ctx.fillText(header.label, header.x, header.y - 2);
|
||||
|
||||
// Subtle underline
|
||||
const labelWidth = ctx.measureText(header.label).width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(header.x - labelWidth / 2, header.y);
|
||||
ctx.lineTo(header.x + labelWidth / 2, header.y);
|
||||
ctx.strokeStyle = hexWithAlpha(header.color, 0.2);
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* KanbanLayoutEngine — positions task nodes in kanban columns relative to their owner.
|
||||
*
|
||||
* Each member/lead gets a zone below them with 4 columns: todo → wip → review → done.
|
||||
* Tasks are pinned (fx/fy) — no d3-force drift. Deterministic layout.
|
||||
* Each member/lead gets a zone below them with columns for non-empty statuses only.
|
||||
* Empty columns are skipped — no wasted space. Each column has a header label.
|
||||
*
|
||||
* Class with ES #private methods, single source of truth from KANBAN_ZONE constants.
|
||||
*/
|
||||
|
|
@ -10,6 +10,31 @@
|
|||
import type { GraphNode } from '../ports/types';
|
||||
import { KANBAN_ZONE } from '../constants/canvas-constants';
|
||||
|
||||
/** Column header info for rendering */
|
||||
export interface KanbanColumnHeader {
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Zone info per owner for rendering headers */
|
||||
export interface KanbanZoneInfo {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
ownerY: number;
|
||||
headers: KanbanColumnHeader[];
|
||||
}
|
||||
|
||||
// Column display config
|
||||
const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
|
||||
todo: { label: 'Todo', color: '#6b7280' },
|
||||
wip: { label: 'In Progress', color: '#3b82f6' },
|
||||
done: { label: 'Done', color: '#22c55e' },
|
||||
review: { label: 'Review', color: '#f59e0b' },
|
||||
approved: { label: 'Approved', color: '#22c55e' },
|
||||
};
|
||||
|
||||
export class KanbanLayoutEngine {
|
||||
// Reusable collections (cleared each call, never GC'd)
|
||||
static readonly #nodeMap = new Map<string, GraphNode>();
|
||||
|
|
@ -17,6 +42,9 @@ export class KanbanLayoutEngine {
|
|||
static readonly #unassigned: GraphNode[] = [];
|
||||
static readonly #colTasks = new Map<string, GraphNode[]>();
|
||||
|
||||
/** Zone info for rendering column headers — updated each layout() call */
|
||||
static zones: KanbanZoneInfo[] = [];
|
||||
|
||||
/**
|
||||
* Position all task nodes in kanban columns relative to their owner.
|
||||
* Call AFTER d3-force settles member positions, BEFORE drawing.
|
||||
|
|
@ -26,7 +54,6 @@ export class KanbanLayoutEngine {
|
|||
nodeMap.clear();
|
||||
for (const n of nodes) nodeMap.set(n.id, n);
|
||||
|
||||
// Group tasks by owner — reuse maps
|
||||
const tasksByOwner = this.#tasksByOwner;
|
||||
tasksByOwner.clear();
|
||||
const unassigned = this.#unassigned;
|
||||
|
|
@ -46,26 +73,27 @@ export class KanbanLayoutEngine {
|
|||
}
|
||||
}
|
||||
|
||||
// Layout each owner's tasks in kanban columns
|
||||
// Reset zones
|
||||
this.zones = [];
|
||||
|
||||
for (const [ownerId, tasks] of tasksByOwner) {
|
||||
const owner = nodeMap.get(ownerId);
|
||||
if (!owner || owner.x == null || owner.y == null) continue;
|
||||
KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y);
|
||||
const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId);
|
||||
if (zoneInfo) this.zones.push(zoneInfo);
|
||||
}
|
||||
|
||||
// Unassigned tasks: separate zone
|
||||
KanbanLayoutEngine.#layoutUnassigned(unassigned);
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number): void {
|
||||
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
|
||||
const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE;
|
||||
const totalWidth = columns.length * columnWidth;
|
||||
const baseX = ownerX - totalWidth / 2;
|
||||
const headerHeight = 20; // space for column header label
|
||||
const baseY = ownerY + offsetY;
|
||||
|
||||
// Classify each task into a column — reuse shared Map
|
||||
// Classify tasks into columns
|
||||
const colTasks = KanbanLayoutEngine.#colTasks;
|
||||
colTasks.clear();
|
||||
for (const col of columns) colTasks.set(col, []);
|
||||
|
|
@ -75,21 +103,47 @@ export class KanbanLayoutEngine {
|
|||
colTasks.get(col)?.push(task);
|
||||
}
|
||||
|
||||
// Position each task in its column + row
|
||||
for (const [colIdx, colName] of columns.entries()) {
|
||||
const colNodes = colTasks.get(colName) ?? [];
|
||||
for (const [rowIdx, task] of colNodes.entries()) {
|
||||
// Collect only NON-EMPTY columns (skip empty — no wasted space)
|
||||
const activeColumns: { name: string; tasks: GraphNode[] }[] = [];
|
||||
for (const colName of columns) {
|
||||
const nodes = colTasks.get(colName) ?? [];
|
||||
if (nodes.length > 0) {
|
||||
activeColumns.push({ name: colName, tasks: nodes });
|
||||
}
|
||||
}
|
||||
|
||||
if (activeColumns.length === 0) return null;
|
||||
|
||||
// Center active columns under owner
|
||||
const totalWidth = activeColumns.length * columnWidth;
|
||||
const baseX = ownerX - totalWidth / 2;
|
||||
|
||||
// Build headers + position tasks
|
||||
const headers: KanbanColumnHeader[] = [];
|
||||
|
||||
for (const [colIdx, col] of activeColumns.entries()) {
|
||||
const colX = baseX + colIdx * columnWidth;
|
||||
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
||||
|
||||
// Column header
|
||||
headers.push({
|
||||
label: config.label,
|
||||
x: colX + columnWidth / 2, // centered in column
|
||||
y: baseY,
|
||||
color: config.color,
|
||||
});
|
||||
|
||||
// Position tasks below header
|
||||
for (const [rowIdx, task] of col.tasks.entries()) {
|
||||
if (rowIdx >= maxVisibleRows) {
|
||||
// Hide overflow tasks off-screen
|
||||
task.x = -99999;
|
||||
task.y = -99999;
|
||||
task.fx = task.x;
|
||||
task.fy = task.y;
|
||||
continue;
|
||||
}
|
||||
const targetX = baseX + colIdx * columnWidth;
|
||||
const targetY = baseY + rowIdx * rowHeight;
|
||||
// Smooth slide: LERP toward target; instant on first appearance
|
||||
const targetX = colX;
|
||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
||||
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||
task.fx = task.x;
|
||||
|
|
@ -98,17 +152,12 @@ export class KanbanLayoutEngine {
|
|||
task.vy = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { ownerId, ownerX, ownerY, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which kanban column a task belongs to.
|
||||
* Columns: todo → wip → done → review → approved
|
||||
* approved is separate from review — approved goes after review.
|
||||
*/
|
||||
static #resolveColumn(task: GraphNode): string {
|
||||
// Approved = separate column (after review)
|
||||
if (task.reviewState === 'approved') return 'approved';
|
||||
// Active review/needsFix = review column (next to done)
|
||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
||||
switch (task.taskStatus) {
|
||||
case 'in_progress':
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import { drawBackground, createDepthParticles, updateDepthParticles, type DepthP
|
|||
import { drawEdges } from '../canvas/draw-edges';
|
||||
import { drawParticles } from '../canvas/draw-particles';
|
||||
import { drawAgents } from '../canvas/draw-agents';
|
||||
import { drawTasks } from '../canvas/draw-tasks';
|
||||
import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks';
|
||||
import { drawProcesses } from '../canvas/draw-processes';
|
||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
|
||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
||||
|
|
@ -208,6 +209,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
|
||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
||||
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||
drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue