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:
iliya 2026-03-28 12:30:38 +02:00
parent 6866f003dc
commit 36336cbd06
3 changed files with 105 additions and 26 deletions

View file

@ -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();
}
}
}

View file

@ -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':

View file

@ -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);