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 { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
|
||||||
import { truncateText } from './draw-misc';
|
import { truncateText } from './draw-misc';
|
||||||
import { hexWithAlpha } from './render-cache';
|
import { hexWithAlpha } from './render-cache';
|
||||||
|
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw all task nodes as pill-shaped cards.
|
* Draw all task nodes as pill-shaped cards.
|
||||||
|
|
@ -176,3 +177,30 @@ function drawTaskPill(
|
||||||
|
|
||||||
ctx.restore();
|
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.
|
* 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.
|
* Each member/lead gets a zone below them with columns for non-empty statuses only.
|
||||||
* Tasks are pinned (fx/fy) — no d3-force drift. Deterministic layout.
|
* 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.
|
* Class with ES #private methods, single source of truth from KANBAN_ZONE constants.
|
||||||
*/
|
*/
|
||||||
|
|
@ -10,6 +10,31 @@
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { KANBAN_ZONE } from '../constants/canvas-constants';
|
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 {
|
export class KanbanLayoutEngine {
|
||||||
// Reusable collections (cleared each call, never GC'd)
|
// Reusable collections (cleared each call, never GC'd)
|
||||||
static readonly #nodeMap = new Map<string, GraphNode>();
|
static readonly #nodeMap = new Map<string, GraphNode>();
|
||||||
|
|
@ -17,6 +42,9 @@ export class KanbanLayoutEngine {
|
||||||
static readonly #unassigned: GraphNode[] = [];
|
static readonly #unassigned: GraphNode[] = [];
|
||||||
static readonly #colTasks = new Map<string, 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.
|
* Position all task nodes in kanban columns relative to their owner.
|
||||||
* Call AFTER d3-force settles member positions, BEFORE drawing.
|
* Call AFTER d3-force settles member positions, BEFORE drawing.
|
||||||
|
|
@ -26,7 +54,6 @@ export class KanbanLayoutEngine {
|
||||||
nodeMap.clear();
|
nodeMap.clear();
|
||||||
for (const n of nodes) nodeMap.set(n.id, n);
|
for (const n of nodes) nodeMap.set(n.id, n);
|
||||||
|
|
||||||
// Group tasks by owner — reuse maps
|
|
||||||
const tasksByOwner = this.#tasksByOwner;
|
const tasksByOwner = this.#tasksByOwner;
|
||||||
tasksByOwner.clear();
|
tasksByOwner.clear();
|
||||||
const unassigned = this.#unassigned;
|
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) {
|
for (const [ownerId, tasks] of tasksByOwner) {
|
||||||
const owner = nodeMap.get(ownerId);
|
const owner = nodeMap.get(ownerId);
|
||||||
if (!owner || owner.x == null || owner.y == null) continue;
|
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);
|
KanbanLayoutEngine.#layoutUnassigned(unassigned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private ──────────────────────────────────────────────────────────────
|
// ─── 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 { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE;
|
||||||
const totalWidth = columns.length * columnWidth;
|
const headerHeight = 20; // space for column header label
|
||||||
const baseX = ownerX - totalWidth / 2;
|
|
||||||
const baseY = ownerY + offsetY;
|
const baseY = ownerY + offsetY;
|
||||||
|
|
||||||
// Classify each task into a column — reuse shared Map
|
// Classify tasks into columns
|
||||||
const colTasks = KanbanLayoutEngine.#colTasks;
|
const colTasks = KanbanLayoutEngine.#colTasks;
|
||||||
colTasks.clear();
|
colTasks.clear();
|
||||||
for (const col of columns) colTasks.set(col, []);
|
for (const col of columns) colTasks.set(col, []);
|
||||||
|
|
@ -75,21 +103,47 @@ export class KanbanLayoutEngine {
|
||||||
colTasks.get(col)?.push(task);
|
colTasks.get(col)?.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position each task in its column + row
|
// Collect only NON-EMPTY columns (skip empty — no wasted space)
|
||||||
for (const [colIdx, colName] of columns.entries()) {
|
const activeColumns: { name: string; tasks: GraphNode[] }[] = [];
|
||||||
const colNodes = colTasks.get(colName) ?? [];
|
for (const colName of columns) {
|
||||||
for (const [rowIdx, task] of colNodes.entries()) {
|
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) {
|
if (rowIdx >= maxVisibleRows) {
|
||||||
// Hide overflow tasks off-screen
|
|
||||||
task.x = -99999;
|
task.x = -99999;
|
||||||
task.y = -99999;
|
task.y = -99999;
|
||||||
task.fx = task.x;
|
task.fx = task.x;
|
||||||
task.fy = task.y;
|
task.fy = task.y;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const targetX = baseX + colIdx * columnWidth;
|
const targetX = colX;
|
||||||
const targetY = baseY + rowIdx * rowHeight;
|
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||||
// Smooth slide: LERP toward target; instant on first appearance
|
|
||||||
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
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.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||||
task.fx = task.x;
|
task.fx = task.x;
|
||||||
|
|
@ -98,17 +152,12 @@ export class KanbanLayoutEngine {
|
||||||
task.vy = 0;
|
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 {
|
static #resolveColumn(task: GraphNode): string {
|
||||||
// Approved = separate column (after review)
|
|
||||||
if (task.reviewState === 'approved') return 'approved';
|
if (task.reviewState === 'approved') return 'approved';
|
||||||
// Active review/needsFix = review column (next to done)
|
|
||||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
||||||
switch (task.taskStatus) {
|
switch (task.taskStatus) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import { drawBackground, createDepthParticles, updateDepthParticles, type DepthP
|
||||||
import { drawEdges } from '../canvas/draw-edges';
|
import { drawEdges } from '../canvas/draw-edges';
|
||||||
import { drawParticles } from '../canvas/draw-particles';
|
import { drawParticles } from '../canvas/draw-particles';
|
||||||
import { drawAgents } from '../canvas/draw-agents';
|
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 { drawProcesses } from '../canvas/draw-processes';
|
||||||
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
import { drawEffects, type VisualEffect } from '../canvas/draw-effects';
|
||||||
import { BloomRenderer } from '../canvas/bloom-renderer';
|
import { BloomRenderer } from '../canvas/bloom-renderer';
|
||||||
|
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||||
|
|
||||||
// ─── Draw State (passed by ref, not by props — no React re-renders) ─────────
|
// ─── 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)
|
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||||
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
|
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
||||||
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue