Add a custom admin view over the technologies collection so staff can model prerequisites as a left-to-right tree instead of a flat list. - React Flow v12 canvas with category bands, OR-prerequisite edges, a lite side-panel editor, and a quick-create dialog. - Propagating layout: a technology with prerequisites lands one column right of its parents at their average row; uncategorized roots fill a catalog grid keyed by each tech's rank among all uncategorized technologies, so promoting one into the tree leaves an empty slot instead of re-shuffling the grid. - Stability contract: connecting or removing edges never moves a card (the connected node is pinned to its current spot), and a "Save positions" control commits every on-screen node so the arrangement survives reload; "Auto-arrange" clears manual positions and restores the ordered layout. - Progression-path edge colors and per-source lanes keep overlapping edges readable. Wire the view and its nav entry into payload.config.ts and add dark React Flow / scrollbar styling.
114 lines
4 KiB
TypeScript
114 lines
4 KiB
TypeScript
import type { TreeTech } from "./types";
|
|
|
|
/**
|
|
* Pure helpers for editing a technology's prerequisites from the tree canvas
|
|
* and the side panel. The tree flattens the document's prerequisite rows into
|
|
* two unions (AND / OR), and edits are written back as a single row, which
|
|
* preserves the meaning of the rows the tree renders.
|
|
*/
|
|
|
|
export type PrereqLists = { and: number[]; or: number[] };
|
|
|
|
export type PrereqEditResult =
|
|
| { ok: true; lists: PrereqLists }
|
|
| { ok: false; error: string };
|
|
|
|
/** Does `fromId` (transitively) depend on `toId`? Cycle probe over prereq edges. */
|
|
export function dependsOn(techs: TreeTech[], fromId: number, toId: number): boolean {
|
|
const byId = new Map(techs.map((t) => [t.id, t]));
|
|
const seen = new Set<number>();
|
|
const stack = [fromId];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!;
|
|
if (current === toId) return true;
|
|
if (seen.has(current)) continue;
|
|
seen.add(current);
|
|
const tech = byId.get(current);
|
|
if (!tech) continue;
|
|
stack.push(...tech.prereqAnd, ...tech.prereqOr);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** The document-shaped prerequisite rows for a pair of id lists. */
|
|
export function buildPrereqRows(lists: PrereqLists) {
|
|
return [
|
|
{
|
|
requiresAnd: { technology: lists.and },
|
|
requiresOr: { technology: lists.or },
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Add `sourceId` as a prerequisite of `targetId`. Rejects self-references,
|
|
* duplicates, and connections that would create a circular dependency.
|
|
*/
|
|
export function addPrerequisite(
|
|
techs: TreeTech[],
|
|
targetId: number,
|
|
sourceId: number,
|
|
kind: "and" | "or",
|
|
): PrereqEditResult {
|
|
const target = techs.find((t) => t.id === targetId);
|
|
if (!target) return { ok: false, error: "Technology not found." };
|
|
if (targetId === sourceId) {
|
|
return { ok: false, error: "A technology cannot be its own prerequisite." };
|
|
}
|
|
if (target.prereqAnd.includes(sourceId) || target.prereqOr.includes(sourceId)) {
|
|
return { ok: false, error: "That technology is already a prerequisite." };
|
|
}
|
|
// Adding source -> target means target depends on source. If source already
|
|
// depends on target, this closes a loop.
|
|
if (dependsOn(techs, sourceId, targetId)) {
|
|
return { ok: false, error: "That would create a circular dependency." };
|
|
}
|
|
const lists: PrereqLists = {
|
|
and: kind === "and" ? [...target.prereqAnd, sourceId] : [...target.prereqAnd],
|
|
or: kind === "or" ? [...target.prereqOr, sourceId] : [...target.prereqOr],
|
|
};
|
|
return { ok: true, lists };
|
|
}
|
|
|
|
/** Remove `sourceId` from both prerequisite lists of `targetId`. */
|
|
export function removePrerequisite(
|
|
techs: TreeTech[],
|
|
targetId: number,
|
|
sourceId: number,
|
|
): PrereqEditResult {
|
|
const target = techs.find((t) => t.id === targetId);
|
|
if (!target) return { ok: false, error: "Technology not found." };
|
|
const lists: PrereqLists = {
|
|
and: target.prereqAnd.filter((id) => id !== sourceId),
|
|
or: target.prereqOr.filter((id) => id !== sourceId),
|
|
};
|
|
return { ok: true, lists };
|
|
}
|
|
|
|
/** Move an existing prerequisite between the AND and OR groups. */
|
|
export function setPrerequisiteKind(
|
|
techs: TreeTech[],
|
|
targetId: number,
|
|
sourceId: number,
|
|
kind: "and" | "or",
|
|
): PrereqEditResult {
|
|
const target = techs.find((t) => t.id === targetId);
|
|
if (!target) return { ok: false, error: "Technology not found." };
|
|
if (!target.prereqAnd.includes(sourceId) && !target.prereqOr.includes(sourceId)) {
|
|
return { ok: false, error: "That technology is not a prerequisite yet." };
|
|
}
|
|
const lists: PrereqLists = {
|
|
and: kind === "and"
|
|
? [...new Set([...target.prereqAnd, sourceId])]
|
|
: target.prereqAnd.filter((id) => id !== sourceId),
|
|
or: kind === "or"
|
|
? [...new Set([...target.prereqOr, sourceId])]
|
|
: target.prereqOr.filter((id) => id !== sourceId),
|
|
};
|
|
return { ok: true, lists };
|
|
}
|
|
|
|
/** Apply a set of prerequisite lists to a TreeTech-shaped object. */
|
|
export function withPrereqs(tech: TreeTech, lists: PrereqLists): TreeTech {
|
|
return { ...tech, prereqAnd: lists.and, prereqOr: lists.or };
|
|
}
|