Files
nanobot/webui/src/components/workbench/workbench-layout.ts
T

433 lines
13 KiB
TypeScript

import type { CSSProperties } from "react";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
interface PaneCell {
xStart: number;
xEnd: number;
yStart: number;
yEnd: number;
}
interface Track {
start: number;
end: number;
}
export interface WorkbenchResizeHandle {
axis: "horizontal" | "vertical";
ratioIndex: number;
position: number;
crossStart: number;
crossEnd: number;
localStart: number;
localEnd: number;
beforeUnitCount: number;
afterUnitCount: number;
}
export interface WorkbenchLayoutGeometry {
gridStyle: CSSProperties;
paneStyles: Array<CSSProperties | undefined>;
resizeHandles: WorkbenchResizeHandle[];
splitRatios: number[];
}
const MIN_RATIO = 0.05;
const MAX_RATIO = 0.95;
const MIN_PANE_EXTENT_PX = 160;
const MAIN_PANE_RATIO = 1.65 / 2.65;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function ratioAt(
ratios: readonly number[],
index: number,
fallback: number,
): number {
const value = ratios[index];
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
}
function tracksTemplate(tracks: readonly Track[]): string {
return tracks
.map((track) => {
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
return `minmax(0, ${weight}fr)`;
})
.join(" ");
}
function sequentialTracks(
count: number,
ratios: readonly number[],
ratioOffset: number,
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
const tracks: Track[] = [];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
let start = 0;
for (let index = 0; index < count - 1; index += 1) {
const remainingPaneCount = count - index;
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
const end = start + (1 - start) * ratio;
tracks.push({ start, end });
handles.push({
axis: "vertical",
ratioIndex: ratioOffset + index,
position: end,
crossStart: 0,
crossEnd: 1,
localStart: start,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: remainingPaneCount - 1,
});
resolvedRatios.push(ratio);
start = end;
}
tracks.push({ start, end: 1 });
return { tracks, handles, resolvedRatios };
}
function cellStyle(
columnStart: number,
columnEnd: number,
rowStart: number,
rowEnd: number,
): CSSProperties {
return {
gridColumn: `${columnStart} / ${columnEnd}`,
gridRow: `${rowStart} / ${rowEnd}`,
};
}
function singlePaneGeometry(): WorkbenchLayoutGeometry {
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: [undefined],
resizeHandles: [],
splitRatios: [],
};
}
function columnsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(split.tracks),
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
resizeHandles: split.handles,
splitRatios: split.resolvedRatios,
};
}
function rowsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: tracksTemplate(split.tracks),
},
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
resizeHandles: split.handles.map((handle) => ({
...handle,
axis: "horizontal",
})),
splitRatios: split.resolvedRatios,
};
}
function gridGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnCount = Math.ceil(Math.sqrt(paneCount));
const rowCount = Math.ceil(paneCount / columnCount);
const columns = sequentialTracks(columnCount, ratios, 0);
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
column: index % columnCount,
row: Math.floor(index / columnCount),
}));
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
...handle,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: columnCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
...handle,
axis: "horizontal" as const,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: rowCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columns.tracks),
gridTemplateRows: tracksTemplate(rows.tracks),
},
paneStyles: paneCells.map((cell) => cellStyle(
cell.column + 1,
cell.column + 2,
cell.row + 1,
cell.row + 2,
)),
resizeHandles: [...verticalHandles, ...horizontalHandles],
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
};
}
function mainStackGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
const stack = sequentialTracks(paneCount - 1, ratios, 1);
const verticalHandle: WorkbenchResizeHandle = {
axis: "vertical",
ratioIndex: 0,
position: columnRatio,
crossStart: 0,
crossEnd: 1,
localStart: 0,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: 1,
};
const stackHandles = stack.handles.map((handle) => ({
...handle,
axis: "horizontal" as const,
crossStart: columnRatio,
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate([
{ start: 0, end: columnRatio },
{ start: columnRatio, end: 1 },
]),
gridTemplateRows: tracksTemplate(stack.tracks),
},
paneStyles: [
cellStyle(1, 2, 1, paneCount),
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
],
resizeHandles: [verticalHandle, ...stackHandles],
splitRatios: [columnRatio, ...stack.resolvedRatios],
};
}
function uniqueBoundaries(values: readonly number[]): number[] {
return Array.from(new Set(values.map((value) => value.toFixed(8))))
.map(Number)
.sort((left, right) => left - right);
}
function boundaryIndex(boundaries: readonly number[], value: number): number {
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
}
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
? [cell.xStart, cell.xEnd]
: [cell.yStart, cell.yEnd]));
return Math.max(1, boundaries.length - 1);
}
function bspGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
const leaf = cells.pop();
if (!leaf) break;
const ratioIndex = paneIndex - 1;
const ratio = ratioAt(ratios, ratioIndex, 0.5);
resolvedRatios.push(ratio);
if (paneIndex % 2 === 1) {
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
cells.push(
{ ...leaf, xEnd: position },
{ ...leaf, xStart: position },
);
handles.push({
axis: "vertical",
ratioIndex,
position,
crossStart: leaf.yStart,
crossEnd: leaf.yEnd,
localStart: leaf.xStart,
localEnd: leaf.xEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
} else {
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
cells.push(
{ ...leaf, yEnd: position },
{ ...leaf, yStart: position },
);
handles.push({
axis: "horizontal",
ratioIndex,
position,
crossStart: leaf.xStart,
crossEnd: leaf.xEnd,
localStart: leaf.yStart,
localEnd: leaf.yEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
}
}
for (const handle of handles) {
if (handle.axis === "vertical") {
const beforeCells = cells.filter((cell) => (
cell.xStart >= handle.localStart
&& cell.xEnd <= handle.position + Number.EPSILON
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.xStart >= handle.position - Number.EPSILON
&& cell.xEnd <= handle.localEnd
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
} else {
const beforeCells = cells.filter((cell) => (
cell.yStart >= handle.localStart
&& cell.yEnd <= handle.position + Number.EPSILON
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.yStart >= handle.position - Number.EPSILON
&& cell.yEnd <= handle.localEnd
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
}
}
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
start,
end: columnBoundaries[index + 1],
}));
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
start,
end: rowBoundaries[index + 1],
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columnTracks),
gridTemplateRows: tracksTemplate(rowTracks),
},
paneStyles: cells.map((cell) => cellStyle(
boundaryIndex(columnBoundaries, cell.xStart) + 1,
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
boundaryIndex(rowBoundaries, cell.yStart) + 1,
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
)),
resizeHandles: handles,
splitRatios: resolvedRatios,
};
}
export function createWorkbenchLayoutGeometry(
layout: WorkbenchLayout,
paneCount: number,
splitRatios: readonly number[],
): WorkbenchLayoutGeometry {
const count = Math.max(1, paneCount);
if (count === 1) return singlePaneGeometry();
switch (layout) {
case "columns":
return columnsGeometry(count, splitRatios);
case "rows":
return rowsGeometry(count, splitRatios);
case "grid":
return gridGeometry(count, splitRatios);
case "main-stack":
return mainStackGeometry(count, splitRatios);
case "bsp":
return bspGeometry(count, splitRatios);
}
}
export function splitRatioBounds(
handle: WorkbenchResizeHandle,
axisExtentPx: number,
): { min: number; max: number } {
const localExtentPx = Math.max(
1,
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
);
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
const paneExtentPx = Math.min(
MIN_PANE_EXTENT_PX,
localExtentPx * 0.8 / paneCount,
);
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
const max = Math.min(
MAX_RATIO,
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
);
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
}
export function resizeHandleRatio(
handle: WorkbenchResizeHandle,
normalizedPosition: number,
axisExtentPx: number,
): number {
const localRatio = (normalizedPosition - handle.localStart)
/ Math.max(0.01, handle.localEnd - handle.localStart);
const bounds = splitRatioBounds(handle, axisExtentPx);
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
}
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
if (handle.axis === "vertical") {
return {
left: `${handle.position * 100}%`,
top: `${handle.crossStart * 100}%`,
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateX(-50%)",
};
}
return {
top: `${handle.position * 100}%`,
left: `${handle.crossStart * 100}%`,
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateY(-50%)",
};
}