1
0
Fork 0

feat(tech-tree): add admin tech tree editor view

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.
This commit is contained in:
Jason Fraley 2026-10-01 10:03:58 -04:00
parent 15f2d0c573
commit 89482798f4
18 changed files with 3280 additions and 0 deletions

View file

@ -23,3 +23,43 @@
@layer utilities {
@import 'tailwindcss/utilities.css';
}
/* Tech Tree canvas: dark tactical chrome for React Flow controls. */
.tech-tree-canvas .react-flow__controls {
button {
background: rgba(0, 0, 0, 0.72);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
border-radius: 0;
&:hover {
background: rgba(0, 0, 0, 0.92);
color: #ffffff;
}
svg { fill: currentColor; }
}
}
.tech-tree-canvas .react-flow__attribution {
background: transparent;
a { color: rgba(255, 255, 255, 0.35); }
}
/* Tech Tree: dark thin scrollbars for the panel and its dropdowns. */
.tech-tree-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.16);
border-radius: 0;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
}

View file

@ -0,0 +1,61 @@
"use client";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import type { CategoryBandLayout } from "./types";
export type CategoryBandData = {
band: CategoryBandLayout;
techCount: number;
};
export type CategoryBandNode = Node<CategoryBandData, "techBand">;
/**
* Full-height column band behind a category's technologies. Non-interactive:
* it is a backdrop, not a node (no handles, no dragging, no selection).
*/
export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
const { band, techCount } = data;
const accent = band.color ?? "#22d3ee";
return (
<div
className="pointer-events-none flex h-full flex-col border bg-black/40"
style={{
borderColor: `${accent}2e`,
boxShadow: "0 0 24px rgba(0,0,0,0.35)",
}}
data-testid={`tech-band-${band.key}`}
>
<div
className="flex items-center justify-between border-b bg-black/50 px-3 py-2"
style={{ borderColor: `${accent}33` }}
>
<span
className="font-mono text-[9px] tracking-widest uppercase"
style={{ color: `${accent}cc` }}
>
{band.label}
</span>
<span className="font-mono text-[9px] tracking-widest text-white/40 uppercase">
{band.position !== null ? `POS ${band.position}` : "AUTO"} · {techCount} TECH
{techCount === 1 ? "" : "S"}
</span>
</div>
<div
className="h-px w-full"
style={{ background: `linear-gradient(90deg, transparent, ${accent}59, transparent)` }}
/>
<div className="flex-1" />
<div className="border-t border-white/10 bg-black/40 px-3 py-1">
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
TIER BAND // {band.label.toUpperCase()}
</span>
</div>
{/* Inert handles so React Flow never treats the band as connectable */}
<Handle type="source" position={Position.Bottom} className="!hidden" isConnectable={false} />
<Handle type="target" position={Position.Top} className="!hidden" isConnectable={false} />
</div>
);
}

View file

@ -0,0 +1,170 @@
"use client";
import { Input } from "@/components/ui/input";
import { Check, PlusIcon } from "lucide-react";
import React from "react";
import type { TreeCategory } from "./types";
/**
* Category picker that both selects existing values and creates a new one
* inline (enter a name that does not exist yet, then pick the Create entry).
* Deliberately a native input + suggestion list instead of a
* Popover/Command combo: the admin dialog's Radix layering closed the
* portal'd list mid-flow, making options unclickable.
*/
export function CategoryPicker({
categories,
canCreateCategories,
value,
onChange,
testIdPrefix = "tech-category",
tone = "light",
}: {
categories: TreeCategory[];
canCreateCategories: boolean;
value: string | null;
onChange: (name: string | null, isNew: boolean) => void;
testIdPrefix?: string;
/** "dark" matches the tactical panel surface; "light" matches shadcn dialogs. */
tone?: "light" | "dark";
}) {
const [search, setSearch] = React.useState("");
const [open, setOpen] = React.useState(false);
const dark = tone === "dark";
const trimmed = search.trim();
const exactMatch = categories.some(
(c) => c.name.toLowerCase() === trimmed.toLowerCase(),
);
const showCreate = canCreateCategories && trimmed.length > 0 && !exactMatch;
const matches = trimmed
? categories.filter((c) => c.name.toLowerCase().includes(trimmed.toLowerCase()))
: categories;
const selected = value ? categories.find((c) => c.name === value) : undefined;
const commit = (name: string, isNew: boolean) => {
onChange(name, isNew);
setSearch("");
setOpen(false);
};
const pickNew = () => commit(trimmed, true);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (showCreate) pickNew();
else if (matches.length === 1) commit(matches[0].name, false);
else if (exactMatch) {
const match = categories.find((c) => c.name.toLowerCase() === trimmed.toLowerCase())!;
commit(match.name, false);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className="relative">
<Input
value={open ? search : value ?? ""}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => {
setSearch("");
setOpen(true);
}}
onKeyDown={handleKeyDown}
onBlur={() => {
window.setTimeout(() => setOpen(false), 120);
}}
placeholder="Select or enter category..."
data-testid={`${testIdPrefix}-input`}
autoComplete="off"
className={
dark
? "rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20"
: undefined
}
/>
{value && !open ? (
<p
className={`mt-1 text-xs ${dark ? "text-white/45" : "text-muted-foreground"}`}
data-testid={`${testIdPrefix}-value`}
>
{selected ? `${selected.name} (position ${selected.position})` : `${value} (new)`}
</p>
) : null}
{open ? (
<div
className={`absolute top-full left-0 z-[1500] mt-1 w-full border shadow-2xl ${
dark ? "rounded-none border-white/20 bg-[#0b0f14]" : "bg-popover rounded-md"
}`}
data-testid={`${testIdPrefix}-options`}
>
<div className="tech-tree-scroll max-h-48 overflow-y-auto p-1">
{showCreate ? (
<button
type="button"
className={`flex w-full items-center gap-2 bg-transparent px-2 py-1.5 text-left text-sm outline-none ${
dark
? "rounded-none border-0 border-b border-b-white/[0.06] text-white/85 hover:bg-white/[0.06]"
: "rounded-sm hover:bg-accent hover:text-accent-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault();
pickNew();
}}
data-testid={`${testIdPrefix}-create-option`}
>
<PlusIcon className="size-4" aria-hidden />
<span>
Create &quot;{trimmed}&quot;
</span>
</button>
) : null}
{matches.map((category) => (
<button
key={category.id}
type="button"
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm outline-none ${
dark
? `rounded-none border-0 border-b border-b-white/[0.06] border-l-2 ${
value === category.name
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
: "border-l-transparent bg-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
}`
: "rounded-sm bg-transparent hover:bg-accent hover:text-accent-foreground"
}`}
onMouseDown={(e) => {
e.preventDefault();
commit(category.name, false);
}}
data-testid={`${testIdPrefix}-option-${category.id}`}
>
<Check
className={`size-4 shrink-0 ${value === category.name ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
<span className="min-w-0">
<span className="block truncate">{category.name}</span>
<span className={`block truncate ${dark ? "font-mono text-[10px] tracking-wide text-white/40 uppercase" : "text-xs text-muted-foreground"}`}>
Position {category.position}
</span>
</span>
</button>
))}
{matches.length === 0 && !showCreate ? (
<p className={`px-2 py-1.5 ${dark ? "font-mono text-xs tracking-wide text-white/40 uppercase" : "text-sm text-muted-foreground"}`}>
{trimmed.length > 0 ? `No category matches "${trimmed}".` : "No categories yet."}
</p>
) : null}
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { Check, ChevronDown } from "lucide-react";
import React from "react";
import { createPortal } from "react-dom";
export type PanelSelectOption = {
value: string;
label: string;
hint?: string;
};
interface PanelSelectProps {
value: string;
onChange: (value: string) => void;
options: PanelSelectOption[];
placeholder?: string;
testId?: string;
disabled?: boolean;
className?: string;
/** Show a search field when the option list is long. */
searchable?: boolean;
}
type Placement = {
left: number;
top: number;
width: number;
maxHeight: number;
openUp: boolean;
};
const LIST_MAX_HEIGHT = 280;
const MIN_LIST_HEIGHT = 140;
/**
* Dark listbox for the tech tree panel. Deliberately NOT a native <select>:
* its popup is browser/OS chrome, ignores the panel's dark styling, and cannot
* be themed reliably.
*
* The option list is rendered through a portal as a `position: fixed` overlay
* (above the panel, so it never pushes the panel's content down) with its own
* coordinates taken from the trigger. It flips above the trigger when there is
* more room up there, and closes on scroll/resize so it can never detach from
* its trigger.
*/
export function PanelSelect({
value,
onChange,
options,
placeholder = "Select...",
testId,
disabled = false,
className = "",
searchable = false,
}: PanelSelectProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [placement, setPlacement] = React.useState<Placement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const measure = React.useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const openUp = spaceBelow < LIST_MAX_HEIGHT && spaceAbove > spaceBelow;
const available = openUp ? spaceAbove : spaceBelow;
setPlacement({
left: rect.left,
top: openUp ? rect.top - 4 : rect.bottom + 4,
width: rect.width,
maxHeight: Math.max(MIN_LIST_HEIGHT, Math.min(LIST_MAX_HEIGHT, available)),
openUp,
});
}, []);
const openList = () => {
measure();
setSearch("");
setOpen(true);
};
const rafRef = React.useRef<number>(0);
/**
* Keep the overlay glued to its trigger. Scrolling INSIDE the option list is
* left alone; any other scroll (the panel body, the window) re-measures the
* trigger and repositions, closing only when the trigger has left the
* viewport. Closing on every scroll made the list vanish when people simply
* scrolled the list itself or the panel behind a short list.
*/
const handleViewportChange = React.useCallback(
(event?: Event) => {
const target = event?.target as Node | undefined;
if (target && listRef.current?.contains(target)) return;
if (rafRef.current) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = 0;
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) {
setOpen(false);
return;
}
measure();
});
},
[measure],
);
React.useEffect(() => {
if (!open) return;
const onDocumentDown = (event: MouseEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (listRef.current?.contains(target)) return;
setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocumentDown);
document.addEventListener("keydown", onKeyDown);
// Capture phase catches scrolling inside the panel's own scroll container.
document.addEventListener("scroll", handleViewportChange, true);
window.addEventListener("resize", handleViewportChange);
return () => {
document.removeEventListener("mousedown", onDocumentDown);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("scroll", handleViewportChange, true);
window.removeEventListener("resize", handleViewportChange);
};
}, [open, handleViewportChange]);
const selected = options.find((option) => option.value === value);
const showSearch = searchable && options.length > 8;
const filtered = search
? options.filter((option) => option.label.toLowerCase().includes(search.toLowerCase()))
: options;
const list =
open && placement ? (
<div
ref={listRef}
data-testid={testId ? `${testId}-list` : undefined}
className="tech-tree-scroll rounded-none border border-white/15 bg-[#0b0f14] shadow-2xl"
style={{
position: "fixed",
left: placement.left,
top: placement.top,
width: placement.width,
zIndex: 100,
transform: placement.openUp ? "translateY(-100%)" : undefined,
}}
>
<div className="flex items-center justify-between border-b border-white/10 bg-black/50 px-2.5 py-1">
<span className="font-mono text-[9px] tracking-widest text-white/45 uppercase">
SELECT // {placeholder.replace(/\.\.\.$/, "")}
</span>
<span className="font-mono text-[9px] tracking-widest text-white/30 uppercase tabular-nums">
{filtered.length}
</span>
</div>
{showSearch ? (
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="SEARCH..."
autoFocus
className="w-full border-0 border-b border-white/10 bg-black/50 px-2.5 py-1.5 font-mono text-xs tracking-wide text-white outline-none placeholder:text-white/30"
/>
) : null}
<div
role="listbox"
className="tech-tree-scroll overflow-y-auto py-1"
style={{ maxHeight: placement.maxHeight - (showSearch ? 36 : 0) }}
>
{filtered.length === 0 ? (
<p className="px-2.5 py-1.5 font-mono text-xs tracking-wide text-white/40 uppercase">
NO MATCHES
</p>
) : null}
{filtered.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === value}
data-testid={testId ? `${testId}-option-${option.value}` : undefined}
onMouseDown={(event) => {
event.preventDefault();
onChange(option.value);
setOpen(false);
}}
className={`flex w-full items-center gap-2 border-0 border-b border-l-2 border-b-white/[0.06] bg-transparent px-2.5 py-1.5 text-left text-[14px] outline-none ${
option.value === value
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
: "border-l-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
}`}
>
<Check
className={`size-4 shrink-0 ${option.value === value ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.hint ? (
<span className="block truncate font-mono text-[10px] tracking-wide text-white/40 uppercase">{option.hint}</span>
) : null}
</span>
</button>
))}
</div>
</div>
) : null;
return (
<div className={`relative ${className}`}>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
data-testid={testId}
onClick={() => (open ? setOpen(false) : openList())}
className="flex h-9 w-full items-center justify-between gap-2 rounded-none border border-white/20 bg-black/40 px-2.5 py-1.5 text-left text-[15px] text-white shadow-xs outline-none hover:border-white/35 focus-visible:border-[#22d3ee]/60 disabled:opacity-50"
>
<span className={`truncate ${selected ? "" : "text-white/45"}`}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
</button>
{typeof document !== "undefined" && list ? createPortal(list, document.body) : null}
</div>
);
}

View file

@ -0,0 +1,239 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PlusIcon } from "lucide-react";
import React from "react";
import { CategoryPicker } from "./CategoryPicker";
import type { TreeCategory } from "./types";
export interface QuickCreateResult {
techId: number;
techName: string;
}
interface QuickCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categories: TreeCategory[];
canCreateCategories: boolean;
onCreated: (result: QuickCreateResult) => void;
}
/**
* Canvas quick-create: minimal fields (name + category) plus the collection's
* other required fields, creating inline categories as needed.
*/
export function QuickCreateDialog({
open,
onOpenChange,
categories,
canCreateCategories,
onCreated,
}: QuickCreateDialogProps) {
const [name, setName] = React.useState("");
const [type, setType] = React.useState<"asset" | "upgrade">("upgrade");
const [summary, setSummary] = React.useState("");
const [minDuration, setMinDuration] = React.useState(1);
const [maxDuration, setMaxDuration] = React.useState(10);
const [categoryName, setCategoryName] = React.useState<string | null>(null);
const [categoryIsNew, setCategoryIsNew] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (open) {
setName("");
setType("upgrade");
setSummary("");
setMinDuration(1);
setMaxDuration(10);
setCategoryName(null);
setCategoryIsNew(false);
setError(null);
}
}, [open]);
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
const submit = async () => {
setError(null);
if (!name.trim()) {
setError("Name is required.");
return;
}
if (!summary.trim()) {
setError("Summary is required.");
return;
}
if (!Number.isFinite(minDuration) || minDuration < 1 || !Number.isFinite(maxDuration) || maxDuration < 1) {
setError("Research durations must be at least 1 tick.");
return;
}
setBusy(true);
try {
let categoryId: number | null = null;
if (categoryName && categoryIsNew) {
const res = await fetch("/api/tech-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: categoryName.trim(), position: maxPosition + 1 }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(
body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`,
);
}
const body = await res.json();
categoryId = body?.doc?.id ?? null;
} else if (categoryName) {
categoryId = categories.find((c) => c.name === categoryName)?.id ?? null;
}
const res = await fetch("/api/technologies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
type,
summary: summary.trim(),
category: categoryId,
researchCosts: {
minimumResearchDuration: Math.max(1, Math.round(minDuration)),
maximumResearchDuration: Math.max(1, Math.round(maxDuration)),
},
}),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(
body?.errors?.[0]?.message || `Could not create technology (HTTP ${res.status}).`,
);
}
const body = await res.json();
const techId = body?.doc?.id;
if (!techId) throw new Error("Technology was created but no id came back.");
onCreated({ techId, techName: name.trim() });
onOpenChange(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error.");
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-none sm:max-w-[440px]" data-testid="tech-tree-create-dialog">
<DialogHeader>
<DialogTitle className="font-mono text-xs tracking-widest text-white/70 uppercase">
New Technology // Quick Create
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="tech-create-name">Name</Label>
<Input
id="tech-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Composite Armor"
data-testid="tech-create-name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tech-create-type">Type</Label>
<select
id="tech-create-type"
value={type}
onChange={(e) => setType(e.target.value as "asset" | "upgrade")}
data-testid="tech-create-type"
className="border-input bg-transparent flex h-9 w-full rounded-md border px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="upgrade">Upgrade</option>
<option value="asset">Asset</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="tech-create-summary">Summary</Label>
<Textarea
id="tech-create-summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="One or two sentences."
rows={3}
data-testid="tech-create-summary"
/>
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<CategoryPicker
categories={categories}
canCreateCategories={canCreateCategories}
value={categoryName}
onChange={(nextName, isNew) => {
setCategoryName(nextName);
setCategoryIsNew(isNew);
}}
/>
{!canCreateCategories ? (
<p className="text-xs text-muted-foreground">
You can select existing categories; creating new ones needs the
tech-categories:create permission.
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="tech-create-min-duration">Min research (ticks)</Label>
<Input
id="tech-create-min-duration"
type="number"
min={1}
value={minDuration}
onChange={(e) => setMinDuration(Number(e.target.value))}
data-testid="tech-create-min-duration"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tech-create-max-duration">Max research (ticks)</Label>
<Input
id="tech-create-max-duration"
type="number"
min={1}
value={maxDuration}
onChange={(e) => setMaxDuration(Number(e.target.value))}
data-testid="tech-create-max-duration"
/>
</div>
</div>
{error ? (
<p className="border border-red-400/40 bg-red-400/10 px-2 py-1.5 text-xs text-red-300" data-testid="tech-create-error">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={submit} disabled={busy} data-testid="tech-create-submit">
<PlusIcon className="size-4" />
{busy ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,128 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { TriangleAlertIcon } from "lucide-react";
import React from "react";
import type { TreeTech } from "./types";
export type TechNodeData = {
tech: TreeTech;
categoryName: string | null;
refLabel: string;
hasWarning: boolean;
connectable: boolean;
};
export type TechNodeModel = Node<TechNodeData, "techCard">;
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
in_progress: { label: "In Progress", className: "text-blue-400" },
ready_for_review: { label: "Ready for Review", className: "text-amber-400" },
approved: { label: "Approved", className: "text-emerald-400" },
rejected: { label: "Rejected", className: "text-red-400" },
revision_requested: { label: "Revision Requested", className: "text-orange-400" },
};
function CornerBrackets() {
const base = "pointer-events-none absolute size-2 border-white/25";
return (
<div aria-hidden className="pointer-events-none absolute inset-1">
<span className={`${base} top-0 left-0 border-t border-l`} />
<span className={`${base} top-0 right-0 border-t border-r`} />
<span className={`${base} bottom-0 left-0 border-b border-l`} />
<span className={`${base} right-0 bottom-0 border-r border-b`} />
</div>
);
}
/**
* Technology card node: tactical dossier styling (squared corners,
* classification strip, corner brackets, mono readouts).
*/
export function TechNodeCard({ data, selected }: NodeProps<TechNodeModel>) {
const { tech, categoryName, refLabel, hasWarning, connectable } = data;
const status = STATUS_LABELS[tech.approvalStatus] ?? {
label: tech.approvalStatus,
className: "text-white/60",
};
return (
<div
data-testid={`tech-node-${tech.id}`}
className="relative h-full w-full"
>
{/* Clipped card surface (the handles live outside this box) */}
<div
className={`flex h-full w-full flex-col overflow-hidden rounded-none border bg-black/60 backdrop-blur-sm transition-colors ${
selected
? "border-[#22d3ee]/70 shadow-[0_0_18px_rgba(34,211,238,0.15)]"
: "border-white/15 hover:border-white/40"
}`}
>
<CornerBrackets />
{/* Classification strip */}
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-white/15 bg-black/50 px-3.5 py-1.5">
<span className="truncate font-mono text-[8px] tracking-widest text-white/50 uppercase">
TECH // {categoryName ?? "UNFILED"}
</span>
<span className="shrink-0 font-mono text-[8px] tracking-widest text-white/40 uppercase">
{refLabel}
</span>
</div>
{/* Body */}
<div className="flex-1 px-3.5 py-2.5">
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm leading-snug font-semibold text-white">
{tech.name}
</span>
{hasWarning ? (
<TriangleAlertIcon
className="mt-0.5 size-3.5 shrink-0 text-amber-400"
aria-label="Timeline warning"
data-testid={`tech-warning-${tech.id}`}
/>
) : null}
</div>
<div className="mt-2 flex items-center gap-2">
<span className="rounded-sm border border-white/15 px-1 py-px font-mono text-[8px] tracking-widest text-white/60 uppercase">
{tech.type}
</span>
<span
className={`font-mono text-[9px] tracking-widest uppercase ${status.className}`}
>
{status.label}
</span>
</div>
</div>
{/* Footer readout */}
<div className="flex shrink-0 items-center justify-between border-t border-white/15 bg-black/60 px-3.5 py-1.5">
<span className="font-mono text-[9px] tracking-widest text-white/50 uppercase tabular-nums">
{tech.type === "asset" ? "UNLOCK" : "UPGRADE"}
</span>
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
{tech.categoryId ? "BAND" : "ZONE"}
</span>
</div>
</div>
<Handle
type="target"
position={Position.Left}
className="!bg-white/45 hover:!bg-[#22d3ee]"
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
isConnectable={connectable}
/>
<Handle
type="source"
position={Position.Right}
className="!bg-white/45 hover:!bg-[#22d3ee]"
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
isConnectable={connectable}
/>
</div>
);
}

View file

@ -0,0 +1,677 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PencilIcon, PlusIcon, RotateCcwIcon, SaveIcon, XIcon } from "lucide-react";
import React from "react";
import { CategoryPicker } from "./CategoryPicker";
import { PanelSelect } from "./PanelSelect";
import { buildPrereqRows } from "./prereqEdits";
import type { TreeCategory, TreeTech } from "./types";
/** Dark tactical form chrome for the panel (it sits on a dark surface). */
const PANEL_CONTROL =
"rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20";
const PANEL_BUTTON =
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
const PANEL_BUTTON_ACCENT =
"rounded-none border border-[#22d3ee]/50 bg-[#22d3ee]/10 font-mono text-[10px] tracking-widest uppercase text-[#67e8f9] hover:border-[#22d3ee]/80 hover:bg-[#22d3ee]/20 hover:text-[#a5f3fc] disabled:opacity-40";
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "in_progress", label: "In Progress" },
{ value: "ready_for_review", label: "Ready for Review" },
{ value: "approved", label: "Approved" },
{ value: "rejected", label: "Rejected" },
{ value: "revision_requested", label: "Revision Requested" },
];
const EDITABLE_STATUS_VALUES = new Set(["in_progress", "ready_for_review"]);
type CostRow = { resource: number | ""; amount: number };
type Draft = {
name: string;
type: "asset" | "upgrade";
approvalStatus: string;
summary: string;
categoryName: string | null;
categoryIsNew: boolean;
prereqAnd: number[];
prereqOr: number[];
costs: CostRow[];
minDuration: number;
maxDuration: number;
};
function draftFromTech(tech: TreeTech, categoryName: string | null): Draft {
return {
name: tech.name,
type: tech.type,
approvalStatus: tech.approvalStatus,
summary: tech.summary,
categoryName,
categoryIsNew: false,
prereqAnd: [...tech.prereqAnd],
prereqOr: [...tech.prereqOr],
costs: tech.resourceCosts.map((c) => ({ resource: c.resource, amount: c.amount })),
minDuration: 1,
maxDuration: 10,
};
}
interface TechPreviewPanelProps {
tech: TreeTech;
categories: TreeCategory[];
resources: { id: number; name: string }[];
allTechs: TreeTech[];
canUpdate: boolean;
canCreateCategories: boolean;
canSetFinalApproval: boolean;
warnings: string[];
onClose: () => void;
onEdit: () => void;
onSaved: (updated: TechTechLike) => void;
}
/** Shape returned by the REST update (only the fields the tree reads). */
export type TechTechLike = Partial<TreeTech> & { id: number };
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="mb-1.5 font-mono text-[10px] tracking-widest text-white/45 uppercase">
{children}
</p>
);
}
/**
* Side dossier panel for the selected technology: a "lite" editor over the
* fields it displays (name, type, approval status, summary, category,
* prerequisites, research costs) plus a jump to the full admin edit view.
*/
export function TechPreviewPanel({
tech,
categories,
resources,
allTechs,
canUpdate,
canCreateCategories,
canSetFinalApproval,
warnings,
onClose,
onEdit,
onSaved,
}: TechPreviewPanelProps) {
const categoryName =
tech.categoryId ? categories.find((c) => c.id === tech.categoryId)?.name ?? null : null;
const [draft, setDraft] = React.useState<Draft>(() => draftFromTech(tech, categoryName));
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [savedAt, setSavedAt] = React.useState<number | null>(null);
// Re-seed the draft when a different technology is selected.
React.useEffect(() => {
setDraft(draftFromTech(tech, categoryName));
setError(null);
setSavedAt(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tech.id]);
const baseline = React.useMemo(() => draftFromTech(tech, categoryName), [tech, categoryName]);
const dirty = JSON.stringify(draft) !== JSON.stringify(baseline);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((prev) => ({ ...prev, [key]: value }));
const nameOf = (id: number) => allTechs.find((t) => t.id === id)?.name ?? `#${id}`;
const resourceName = (id: number) => resources.find((r) => r.id === id)?.name ?? `#${id}`;
const statusLocked =
!canSetFinalApproval && !EDITABLE_STATUS_VALUES.has(draft.approvalStatus);
const addableTechs = React.useMemo(
() =>
allTechs
.filter(
(t) =>
t.id !== tech.id &&
!draft.prereqAnd.includes(t.id) &&
!draft.prereqOr.includes(t.id),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[allTechs, tech.id, draft.prereqAnd, draft.prereqOr],
);
const [prereqToAdd, setPrereqToAdd] = React.useState<string>("");
const [prereqKind, setPrereqKind] = React.useState<"and" | "or">("and");
const movePrereq = (id: number, kind: "and" | "or") => {
setDraft((prev) => ({
...prev,
prereqAnd:
kind === "and"
? [...new Set([...prev.prereqAnd, id])]
: prev.prereqAnd.filter((x) => x !== id),
prereqOr:
kind === "or"
? [...new Set([...prev.prereqOr, id])]
: prev.prereqOr.filter((x) => x !== id),
}));
};
const removePrereq = (id: number) =>
setDraft((prev) => ({
...prev,
prereqAnd: prev.prereqAnd.filter((x) => x !== id),
prereqOr: prev.prereqOr.filter((x) => x !== id),
}));
const save = async () => {
setError(null);
if (!draft.name.trim()) {
setError("Name is required.");
return;
}
if (!draft.summary.trim()) {
setError("Summary is required.");
return;
}
if (draft.minDuration < 1 || draft.maxDuration < 1) {
setError("Research durations must be at least 1 tick.");
return;
}
const costRows = draft.costs.filter((c) => c.resource !== "");
if (costRows.some((c) => !Number.isFinite(c.amount) || c.amount < 0)) {
setError("Resource amounts must be zero or more.");
return;
}
setBusy(true);
try {
let categoryId: number | null = null;
if (draft.categoryName && draft.categoryIsNew) {
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
const res = await fetch("/api/tech-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: draft.categoryName.trim(), position: maxPosition + 1 }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`);
}
categoryId = (await res.json())?.doc?.id ?? null;
} else if (draft.categoryName) {
categoryId = categories.find((c) => c.name === draft.categoryName)?.id ?? null;
}
const data: Record<string, unknown> = {
name: draft.name.trim(),
type: draft.type,
summary: draft.summary.trim(),
category: categoryId,
requirements: {
prerequisiteTechnologies: buildPrereqRows({
and: draft.prereqAnd,
or: draft.prereqOr,
}),
},
researchCosts: {
resources: costRows.map((c) => ({ resource: c.resource, amount: c.amount })),
minimumResearchDuration: Math.max(1, Math.round(draft.minDuration)),
maximumResearchDuration: Math.max(1, Math.round(draft.maxDuration)),
},
};
if (!statusLocked) data.approvalStatus = draft.approvalStatus;
const res = await fetch(`/api/technologies/${tech.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
}
setSavedAt(Date.now());
onSaved({ ...tech, id: tech.id });
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error.");
} finally {
setBusy(false);
}
};
return (
<aside
data-testid="tech-preview-panel"
className="absolute top-4 right-4 bottom-4 z-20 flex w-[400px] flex-col rounded-none border border-white/15 bg-black/75 backdrop-blur-md"
>
{/* Classification strip */}
<div className="flex shrink-0 items-center justify-between border-b border-white/15 bg-black/50 px-4 py-2.5">
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
TECH DOSSIER // {canUpdate ? "EDIT" : "READ"}
</span>
<span className="font-mono text-[10px] tracking-widest text-white/40 uppercase">
REF {tech.id.toString().padStart(3, "0")}
</span>
</div>
{/* Header */}
<div className="flex shrink-0 items-start justify-between gap-2 border-b border-white/10 px-4 py-3">
<div className="min-w-0">
<h2 className="truncate text-lg leading-tight font-semibold text-white">
{draft.name || "Untitled"}
</h2>
<p className="mt-1 font-mono text-[10px] tracking-widest text-white/45 uppercase">
{draft.categoryName ? `CAT: ${draft.categoryName}` : "CAT: UNFILED"}
{dirty ? " // UNSAVED" : ""}
{savedAt && !dirty ? " // SAVED" : ""}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
onClick={onClose}
aria-label="Close preview"
data-testid="tech-preview-close"
>
<XIcon className="size-4" />
</Button>
</div>
{/* Body */}
<div className="tech-tree-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4">
{canUpdate ? (
<>
<div>
<SectionLabel>Name</SectionLabel>
<Input
value={draft.name}
onChange={(e) => set("name", e.target.value)}
data-testid="tech-panel-name"
className={PANEL_CONTROL}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<SectionLabel>Type</SectionLabel>
<PanelSelect
value={draft.type}
onChange={(value) => set("type", value as "asset" | "upgrade")}
options={[
{ value: "upgrade", label: "Upgrade" },
{ value: "asset", label: "Asset" },
]}
testId="tech-panel-type"
/>
</div>
<div>
<SectionLabel>Approval Status</SectionLabel>
{statusLocked ? (
<p className="text-[15px] text-white/85" data-testid="tech-panel-status-locked">
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
draft.approvalStatus}
<span className="mt-1 block text-xs text-white/45">
Final states are set by superusers.
</span>
</p>
) : (
<PanelSelect
value={draft.approvalStatus}
onChange={(value) => set("approvalStatus", value)}
options={STATUS_OPTIONS.filter(
(o) => canSetFinalApproval || EDITABLE_STATUS_VALUES.has(o.value),
).map((o) => ({ value: o.value, label: o.label }))}
testId="tech-panel-status"
/>
)}
</div>
</div>
<div>
<SectionLabel>Summary</SectionLabel>
<Textarea
value={draft.summary}
onChange={(e) => set("summary", e.target.value)}
rows={3}
data-testid="tech-panel-summary"
className={PANEL_CONTROL}
/>
</div>
<div>
<SectionLabel>Category</SectionLabel>
<CategoryPicker
categories={categories}
canCreateCategories={canCreateCategories}
value={draft.categoryName}
onChange={(name, isNew) =>
setDraft((prev) => ({ ...prev, categoryName: name, categoryIsNew: isNew }))
}
testIdPrefix="tech-panel-category"
tone="dark"
/>
</div>
</>
) : (
<>
<div>
<SectionLabel>Type</SectionLabel>
<p className="text-[15px] text-white/85 capitalize">{draft.type}</p>
</div>
<div>
<SectionLabel>Approval Status</SectionLabel>
<p className="text-[15px] text-white/85">
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
draft.approvalStatus}
</p>
</div>
<div>
<SectionLabel>Summary</SectionLabel>
<p className="text-[15px] leading-relaxed text-white/80">
{draft.summary || "None."}
</p>
</div>
</>
)}
{/* Prerequisites */}
<div>
<SectionLabel>Prerequisites</SectionLabel>
{draft.prereqAnd.length === 0 && draft.prereqOr.length === 0 ? (
<p className="text-[15px] text-white/50">No prerequisites.</p>
) : (
<div className="space-y-2">
{(
[
["and", draft.prereqAnd],
["or", draft.prereqOr],
] as const
).map(([kind, ids]) =>
ids.length === 0 ? null : (
<div key={kind}>
<p className="mb-1 font-mono text-[10px] tracking-widest text-white/40 uppercase">
{kind.toUpperCase()}
</p>
<ul className="space-y-1">
{ids.map((id) => (
<li
key={`${kind}-${id}`}
className="flex items-center justify-between gap-2 border border-white/10 bg-black/40 px-2.5 py-1.5"
data-testid={`tech-panel-prereq-${kind.toLowerCase()}-${id}`}
>
<span className="truncate text-[15px] text-white/85">{nameOf(id)}</span>
{canUpdate ? (
<span className="flex shrink-0 items-center gap-1">
<button
type="button"
className="border border-white/20 bg-black/40 px-1.5 py-0.5 font-mono text-[9px] tracking-widest text-white/60 uppercase hover:border-white/45 hover:text-white"
onClick={() => movePrereq(id, kind === "and" ? "or" : "and")}
title={`Move to ${kind === "and" ? "OR" : "AND"}`}
data-testid={`tech-panel-prereq-${id}-toggle`}
>
{kind === "and" ? "→OR" : "→AND"}
</button>
<button
type="button"
className="bg-transparent text-white/50 hover:text-red-400"
onClick={() => removePrereq(id)}
aria-label={`Remove ${nameOf(id)}`}
data-testid={`tech-panel-prereq-${id}-remove`}
>
<XIcon className="size-3.5" />
</button>
</span>
) : null}
</li>
))}
</ul>
</div>
),
)}
</div>
)}
{canUpdate && addableTechs.length > 0 ? (
<div className="mt-2 flex items-start gap-1.5">
<PanelSelect
value={prereqToAdd}
onChange={setPrereqToAdd}
placeholder="Add prerequisite..."
options={addableTechs.map((t) => ({ value: String(t.id), label: t.name }))}
testId="tech-panel-add-prereq"
searchable
className="flex-1"
/>
<PanelSelect
value={prereqKind}
onChange={(value) => setPrereqKind(value as "and" | "or")}
options={[
{ value: "and", label: "AND" },
{ value: "or", label: "OR" },
]}
testId="tech-panel-add-prereq-kind"
className="w-24 shrink-0"
/>
<Button
size="sm"
variant="outline"
disabled={!prereqToAdd}
onClick={() => {
if (!prereqToAdd) return;
movePrereq(Number(prereqToAdd), prereqKind);
setPrereqToAdd("");
}}
data-testid="tech-panel-add-prereq-confirm"
className={`shrink-0 ${PANEL_BUTTON}`}
>
<PlusIcon className="size-4" />
</Button>
</div>
) : null}
</div>
{/* Research costs */}
<div>
<SectionLabel>Research Costs</SectionLabel>
{canUpdate ? (
<div className="space-y-1.5">
{draft.costs.length === 0 ? (
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
) : null}
{draft.costs.map((cost, index) => (
<div key={index} className="flex items-start gap-1.5">
<PanelSelect
value={cost.resource === "" ? "" : String(cost.resource)}
onChange={(value) =>
setDraft((prev) => ({
...prev,
costs: prev.costs.map((c, i) =>
i === index
? { ...c, resource: value === "" ? "" : Number(value) }
: c,
),
}))
}
placeholder="Select resource..."
options={resources.map((r) => ({ value: String(r.id), label: r.name }))}
testId={`tech-panel-cost-${index}-resource`}
searchable
className="flex-1"
/>
<Input
type="number"
min={0}
value={cost.amount}
onChange={(e) =>
setDraft((prev) => ({
...prev,
costs: prev.costs.map((c, i) =>
i === index ? { ...c, amount: Number(e.target.value) } : c,
),
}))
}
className={`${PANEL_CONTROL} w-24 shrink-0`}
data-testid={`tech-panel-cost-${index}-amount`}
/>
<button
type="button"
className="shrink-0 bg-transparent text-white/50 hover:text-red-400"
onClick={() =>
setDraft((prev) => ({
...prev,
costs: prev.costs.filter((_, i) => i !== index),
}))
}
aria-label="Remove cost"
>
<XIcon className="size-3.5" />
</button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() =>
setDraft((prev) => ({
...prev,
costs: [...prev.costs, { resource: "", amount: 1 }],
}))
}
data-testid="tech-panel-cost-add"
className={PANEL_BUTTON}
>
<PlusIcon className="size-4" />
Add resource
</Button>
<div className="grid grid-cols-2 gap-3 pt-1">
<div>
<SectionLabel>Min duration (ticks)</SectionLabel>
<Input
type="number"
min={1}
value={draft.minDuration}
onChange={(e) => set("minDuration", Number(e.target.value))}
className={PANEL_CONTROL}
data-testid="tech-panel-min-duration"
/>
</div>
<div>
<SectionLabel>Max duration (ticks)</SectionLabel>
<Input
type="number"
min={1}
value={draft.maxDuration}
onChange={(e) => set("maxDuration", Number(e.target.value))}
className={PANEL_CONTROL}
data-testid="tech-panel-max-duration"
/>
</div>
</div>
</div>
) : draft.costs.length > 0 ? (
<ul className="space-y-1">
{draft.costs.map((cost, i) => (
<li
key={i}
className="flex items-center justify-between border border-white/10 bg-black/40 px-2.5 py-1.5"
>
<span className="truncate text-[15px] text-white/80">
{cost.resource === "" ? "Unset" : resourceName(cost.resource)}
</span>
<span className="ml-2 font-mono text-sm text-white/60 tabular-nums">
{cost.amount}
</span>
</li>
))}
</ul>
) : (
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
)}
</div>
{warnings.length > 0 ? (
<div className="border border-amber-400/40 bg-amber-400/10 px-2.5 py-2">
<SectionLabel>Timeline Warnings</SectionLabel>
<ul className="list-inside list-disc space-y-1">
{warnings.map((warning, i) => (
<li key={i} className="text-sm leading-relaxed text-amber-300">
{warning}
</li>
))}
</ul>
</div>
) : null}
{error ? (
<p
className="border border-red-400/40 bg-red-400/10 px-2.5 py-1.5 text-sm text-red-300"
data-testid="tech-panel-error"
>
{error}
</p>
) : null}
</div>
{/* Readout footer */}
<div className="flex shrink-0 border-t border-white/15 bg-black/60 backdrop-blur-sm">
<div className="flex-1 border-r border-white/15 px-4 py-2">
<p className="font-mono text-sm text-white/85 tabular-nums">
{draft.type.toUpperCase()}
</p>
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">KIND</p>
</div>
<div className="flex-1 px-4 py-2">
<p className="font-mono text-sm text-white/85 tabular-nums">
{draft.prereqAnd.length + draft.prereqOr.length}
</p>
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">PREREQS</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-white/15 p-3">
{canUpdate ? (
<>
<Button
onClick={save}
disabled={busy || !dirty}
className={`flex-1 ${PANEL_BUTTON_ACCENT}`}
data-testid="tech-panel-save"
>
<SaveIcon className="size-4" />
{busy ? "Saving..." : dirty ? "Save changes" : "Saved"}
</Button>
<Button
variant="outline"
disabled={busy || !dirty}
onClick={() => {
setDraft(draftFromTech(tech, categoryName));
setError(null);
}}
title="Discard changes"
data-testid="tech-panel-reset"
className={PANEL_BUTTON}
>
<RotateCcwIcon className="size-4" />
</Button>
</>
) : null}
<Button
variant={canUpdate ? "outline" : "default"}
onClick={onEdit}
className={`${canUpdate ? "flex-1" : "w-full"} ${PANEL_BUTTON}`}
data-testid="tech-preview-edit"
>
<PencilIcon className="size-4" />
Edit in full view
</Button>
</div>
</aside>
);
}

View file

@ -0,0 +1,694 @@
"use client";
import {
Background,
BackgroundVariant,
Controls,
MarkerType,
ReactFlow,
useEdgesState,
useNodesState,
type Edge,
type EdgeChange,
type Node,
type NodeChange,
type NodeTypes,
ReactFlowProvider,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LayoutGridIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { Button } from "@/components/ui/button";
import { CategoryBand, type CategoryBandData, type CategoryBandNode } from "./CategoryBand";
import { edgeVisuals, type EdgeRef } from "./edgeVisuals";
import { applyPins, pinsToFreeze, type CanvasPosition } from "./freeze";
import { computeLayout } from "./layout";
import { mapCategoryDoc, mapTechDoc } from "./mappers";
import { addPrerequisite, buildPrereqRows, removePrerequisite, withPrereqs } from "./prereqEdits";
import { QuickCreateDialog, type QuickCreateResult } from "./QuickCreateDialog";
import { TechNodeCard, type TechNodeData, type TechNodeModel } from "./TechNode";
import { TechPreviewPanel } from "./TechPreviewPanel";
import {
COLUMN_STEP,
NODE_HEIGHT,
NODE_WIDTH,
type TreeCategory,
type TreeTech,
} from "./types";
type ResourceEntry = { id: number; name: string };
export interface TechTreeData {
techs: TreeTech[];
categories: TreeCategory[];
resources: ResourceEntry[];
canCreate: boolean;
canCreateCategories: boolean;
canUpdate: boolean;
canSetFinalApproval: boolean;
}
/** Tactical chrome for the canvas toolbar controls. */
const TACTICAL_BUTTON =
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
const nodeTypes: NodeTypes = {
techCard: TechNodeCard,
techBand: CategoryBand,
};
function refLabel(id: number): string {
return `REF ${id.toString().padStart(3, "0")}`;
}
function buildFlowNodes(
techs: TreeTech[],
categories: TreeCategory[],
layout: ReturnType<typeof computeLayout>,
connectable: boolean,
): Node[] {
const warningsByTech = new Map<number, number>();
for (const warning of layout.warnings) {
warningsByTech.set(warning.techId, (warningsByTech.get(warning.techId) ?? 0) + 1);
}
const categoryById = new Map(categories.map((c) => [c.id, c]));
const bandNodes: CategoryBandNode[] = layout.bands.map((band) => {
const techCount =
band.key === "uncategorized"
? techs.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)).length
: techs.filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))).length;
return {
id: band.key,
type: "techBand",
position: { x: band.x, y: band.y },
draggable: false,
selectable: false,
deletable: false,
zIndex: 0,
// Bands are pure backdrops: they must never swallow pointer events,
// otherwise edges under a band cannot be selected and pane clicks die.
style: { width: band.width, height: band.height, pointerEvents: "none" },
data: { band, techCount },
};
});
const techById = new Map(techs.map((t) => [t.id, t]));
const techNodes: TechNodeModel[] = layout.nodes.map((node) => {
const tech = techById.get(node.techId)!;
return {
id: String(tech.id),
type: "techCard",
position: { x: node.x, y: node.y },
zIndex: 1,
style: { width: NODE_WIDTH, height: NODE_HEIGHT },
data: {
tech,
categoryName: tech.categoryId ? categoryById.get(tech.categoryId)?.name ?? null : null,
refLabel: refLabel(tech.id),
hasWarning: warningsByTech.has(tech.id),
connectable,
},
};
});
return [...bandNodes, ...techNodes];
}
function withAlpha(hex: string, alpha: number): string {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/**
* Prerequisite edges. Each edge takes its progression path's color and its own
* lane (see edgeVisuals). With a technology selected, edges that do not touch
* it fade back so its connections stand out.
*/
function buildFlowEdges(
techs: TreeTech[],
layout: ReturnType<typeof computeLayout>,
selectedId: string | null,
): Edge[] {
const known = new Set(techs.map((t) => t.id));
const refs: EdgeRef[] = [];
const isOr = new Set<string>();
for (const tech of techs) {
for (const prereqId of tech.prereqAnd) {
if (prereqId === tech.id || !known.has(prereqId)) continue;
refs.push({ id: `and-${prereqId}-${tech.id}`, source: prereqId, target: tech.id });
}
for (const prereqId of tech.prereqOr) {
if (prereqId === tech.id || !known.has(prereqId)) continue;
const id = `or-${prereqId}-${tech.id}`;
if (refs.some((r) => r.id === id)) continue;
refs.push({ id, source: prereqId, target: tech.id });
isOr.add(id);
}
}
const positions = new Map(layout.nodes.map((n) => [n.techId, { x: n.x, y: n.y }]));
const visuals = edgeVisuals(techs, refs, positions, COLUMN_STEP);
const focus = selectedId ? Number(selectedId) : null;
return refs.map((ref) => {
const visual = visuals.get(ref.id)!;
const or = isOr.has(ref.id);
const related = focus === null || ref.source === focus || ref.target === focus;
const alpha = focus === null ? 0.8 : related ? 1 : 0.12;
const stroke = withAlpha(visual.color, alpha);
return {
id: ref.id,
source: String(ref.source),
target: String(ref.target),
type: "smoothstep",
pathOptions: { stepPosition: visual.stepPosition, borderRadius: 8 },
animated: or,
zIndex: focus !== null && related ? 2 : 0,
style: {
stroke,
strokeWidth: focus !== null && related ? 2.25 : 1.5,
...(or ? { strokeDasharray: "6 4" } : {}),
},
markerEnd: { type: MarkerType.ArrowClosed, color: stroke },
...(or
? {
label: "OR",
labelStyle: { fill: stroke, fontSize: 9, fontFamily: "monospace" },
labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 },
}
: {}),
data: { pathRoot: visual.pathRoot },
} as Edge;
});
}
async function fetchCollection<T>(slug: string): Promise<T[]> {
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
credentials: "same-origin",
});
if (!res.ok) return [];
const body = await res.json();
return body?.docs ?? [];
}
function CanvasInner({ data }: { data: TechTreeData }) {
const router = useRouter();
const [techs, setTechs] = React.useState(data.techs);
const [categories, setCategories] = React.useState(data.categories);
const [resources, setResources] = React.useState(data.resources);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const [arranging, setArranging] = React.useState(false);
const layout = React.useMemo(() => computeLayout(techs, categories), [techs, categories]);
const flowNodes = React.useMemo(
() => buildFlowNodes(techs, categories, layout, data.canUpdate),
[techs, categories, layout, data.canUpdate],
);
const flowEdges = React.useMemo(
() => buildFlowEdges(techs, layout, selectedId),
[techs, layout, selectedId],
);
// Controlled nodes/edges: React Flow needs the change handlers to apply
// drag and selection updates in controlled mode.
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
React.useEffect(() => {
setNodes(flowNodes);
}, [flowNodes, setNodes]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
React.useEffect(() => {
setEdges(flowEdges);
}, [flowEdges, setEdges]);
// Latest rendered card positions, read by the freeze diff below.
const nodesRef = React.useRef<Node[]>([]);
nodesRef.current = nodes;
const currentPositions = React.useCallback((): Map<number, CanvasPosition> => {
const map = new Map<number, CanvasPosition>();
for (const node of nodesRef.current) {
if (node.type !== "techCard") continue;
map.set(Number(node.id), { x: node.position.x, y: node.position.y });
}
return map;
}, []);
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
const persistPins = React.useCallback(
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
await Promise.all(
[...pins]
.filter(([id]) => !skip.has(id))
.map(([id, pin]) =>
fetch(`/api/technologies/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: pin }),
}),
),
);
},
[],
);
// Nodes are data-driven: block React Flow's own remove changes so Backspace
// can never blank a node out of the canvas (positions/selection still flow).
const handleNodesChange = React.useCallback(
(changes: NodeChange<Node>[]) => {
const allowed = changes.filter((change) => change.type !== "remove");
if (allowed.length > 0) onNodesChange(allowed);
},
[onNodesChange],
);
// Edge removal is a data change (it drops a prerequisite), handled in
// onEdgesDelete; selection changes still flow so edges can be picked.
const handleEdgesChange = React.useCallback(
(changes: EdgeChange[]) => {
const allowed = changes.filter((change) => change.type !== "remove");
if (allowed.length > 0) onEdgesChange(allowed);
},
[onEdgesChange],
);
const [connectError, setConnectError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!connectError) return;
const timer = window.setTimeout(() => setConnectError(null), 6000);
return () => window.clearTimeout(timer);
}, [connectError]);
const hasManualPositions = techs.some((t) => t.treePosition !== null);
const [saveStatus, setSaveStatus] = React.useState<string | null>(null);
React.useEffect(() => {
if (!saveStatus) return;
const timer = window.setTimeout(() => setSaveStatus(null), 3000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
const patchPrereqs = React.useCallback(
async (
targetId: number,
lists: { and: number[]; or: number[] },
pin?: { x: number; y: number },
) => {
const res = await fetch(`/api/technologies/${targetId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
requirements: { prerequisiteTechnologies: buildPrereqRows(lists) },
...(pin ? { treePosition: pin } : {}),
}),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
}
},
[],
);
const handleConnect = React.useCallback(
async (connection: { source: string | null; target: string | null }) => {
if (!connection.source || !connection.target) return;
const sourceId = Number(connection.source);
const targetId = Number(connection.target);
const result = addPrerequisite(techs, targetId, sourceId, "and");
if (!result.ok) {
setConnectError(result.error);
return;
}
setConnectError(null);
// Stability: nothing on screen moves because of a connection. Every card
// the ordered layout would relocate (the target, its dependents, column
// neighbours) is pinned where it sits; "Auto-arrange" re-orders them.
const updated = techs.map((t) =>
t.id === targetId ? withPrereqs(t, result.lists) : t,
);
const pins = pinsToFreeze(currentPositions(), updated, categories);
setTechs(applyPins(updated, pins));
try {
await patchPrereqs(targetId, result.lists, pins.get(targetId));
await persistPins(pins, new Set([targetId]));
} catch (e) {
setConnectError(e instanceof Error ? e.message : "Could not save the connection.");
await refresh();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[techs, categories, patchPrereqs, currentPositions, persistPins],
);
const handleEdgesDelete = React.useCallback(
async (deleted: Edge[]) => {
let next = techs;
const listsByTarget = new Map<number, { and: number[]; or: number[] }>();
for (const edge of deleted) {
const targetId = Number(edge.target);
const result = removePrerequisite(next, targetId, Number(edge.source));
if (!result.ok) continue;
listsByTarget.set(targetId, result.lists);
next = next.map((t) => (t.id === targetId ? withPrereqs(t, result.lists) : t));
}
if (listsByTarget.size === 0) return;
const pins = pinsToFreeze(currentPositions(), next, categories);
setTechs(applyPins(next, pins));
try {
for (const [targetId, lists] of listsByTarget) {
await patchPrereqs(targetId, lists, pins.get(targetId));
}
await persistPins(pins, new Set(listsByTarget.keys()));
} catch (e) {
setConnectError(e instanceof Error ? e.message : "Could not remove the prerequisite.");
await refresh();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[techs, categories, patchPrereqs, currentPositions, persistPins],
);
const persistPosition = React.useCallback(async (techId: number, x: number, y: number) => {
setTechs((prev) =>
prev.map((t) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)),
);
try {
await fetch(`/api/technologies/${techId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: { x, y } }),
});
} catch {
// Optimistic position stays locally; a reload restores the stored value.
}
}, []);
const handleSavePositions = React.useCallback(async () => {
// Commit the current on-screen position of every node to treePosition, so
// the exact arrangement survives a reload. Unlike a drag (one node) or a
// connection (only what would move), this pins the whole tree to whatever
// React Flow actually rendered.
const pins = new Map<number, CanvasPosition>();
const current = currentPositions();
for (const tech of techs) {
const pos = current.get(tech.id);
if (pos) {
pins.set(tech.id, { x: Math.round(pos.x), y: Math.round(pos.y) });
}
}
if (pins.size === 0) {
setSaveStatus("Nothing to save.");
return;
}
setTechs((prev) =>
prev.map((t) => {
const pin = pins.get(t.id);
return pin ? { ...t, treePosition: pin } : t;
}),
);
try {
await persistPins(pins);
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
} catch (e) {
setSaveStatus("Could not save positions.");
}
}, [techs, currentPositions, persistPins]);
const handleAutoArrange = React.useCallback(async () => {
const withPositions = techs.filter((t) => t.treePosition !== null);
if (withPositions.length === 0) return;
setArranging(true);
try {
await Promise.all(
withPositions.map((t) =>
fetch(`/api/technologies/${t.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ treePosition: { x: null, y: null } }),
}),
),
);
setTechs((prev) => prev.map((t) => ({ ...t, treePosition: null })));
} finally {
setArranging(false);
}
}, [techs]);
const techById = React.useMemo(
() => new Map(techs.map((t) => [String(t.id), t])),
[techs],
);
const selectedTech = selectedId ? techById.get(selectedId) ?? null : null;
/**
* Re-read the tree. With `freeze`, cards the fresh data would relocate are
* pinned at their current spot first (panel saves, quick-create), so the
* canvas stays still. Error recovery calls it without freeze to show truth.
*/
const refresh = React.useCallback(
async (options: { freeze?: boolean } = {}) => {
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
fetchCollection<Record<string, unknown>>("technologies"),
fetchCollection<Record<string, unknown>>("tech-categories"),
fetchCollection<Record<string, unknown>>("resources"),
]);
let nextTechs = techDocs.map(mapTechDoc);
const nextCategories = categoryDocs.map(mapCategoryDoc);
if (options.freeze) {
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
nextTechs = applyPins(nextTechs, pins);
void persistPins(pins);
}
setTechs(nextTechs);
setCategories(nextCategories);
setResources(resourceDocs.map((d) => ({ id: d.id as number, name: String(d.name ?? "") })));
},
[currentPositions, persistPins],
);
const handleCreated = React.useCallback(
async (result: QuickCreateResult) => {
await refresh({ freeze: true });
setSelectedId(String(result.techId));
router.push(`/admin/collections/technologies/${result.techId}`);
},
[refresh, router],
);
const selectedWarnings = React.useMemo(
() =>
selectedTech
? layout.warnings.filter((w) => w.techId === selectedTech.id).map((w) => w.message)
: [],
[selectedTech, layout],
);
return (
<div
className="relative tech-tree-canvas"
style={{
height: "calc(100dvh - 56px)",
backgroundColor: "#05070a",
}}
data-testid="tech-tree-canvas"
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
minZoom={0.08}
nodesConnectable={data.canUpdate}
onConnect={(connection) => {
void handleConnect(connection);
}}
onConnectEnd={(event, connectionState) => {
// React Flow only resolves drops onto a handle. If the pointer was
// released over a node card instead, resolve that card ourselves so
// "drag onto the technology" works the way people expect. Validation
// (duplicates, cycles) lives in handleConnect, which reports errors.
if (!data.canUpdate) return;
if (!("fromNode" in connectionState)) return;
if (connectionState.toHandle) return; // dropped on a handle: onConnect leads
const sourceId = connectionState.fromNode?.id;
if (!sourceId) return;
const point = event as MouseEvent;
const element = document.elementFromPoint(point.clientX ?? 0, point.clientY ?? 0);
const nodeElement = element?.closest?.(".react-flow__node") as HTMLElement | null;
const targetId = nodeElement?.getAttribute("data-id");
if (!targetId || targetId === sourceId) return;
void handleConnect({ source: sourceId, target: targetId });
}}
onEdgesDelete={(deleted) => {
void handleEdgesDelete(deleted);
}}
onNodeClick={(_, node) => {
if (node.type === "techCard") setSelectedId(node.id);
}}
onNodeDragStop={(_, node) => {
if (node.type !== "techCard") return;
void persistPosition(Number(node.id), Math.round(node.position.x), Math.round(node.position.y));
}}
onPaneClick={() => setSelectedId(null)}
proOptions={{ hideAttribution: false }}
>
<Background
variant={BackgroundVariant.Lines}
gap={28}
size={1}
color="rgba(255,255,255,0.055)"
/>
<Controls showInteractive={false} />
</ReactFlow>
{/* Toolbar */}
<div className="absolute top-4 left-4 z-20 flex items-center gap-3 rounded-none border border-white/15 bg-black/60 px-3 py-2 backdrop-blur-sm">
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
TECH TREE // COMMAND
</span>
{layout.warnings.length > 0 ? (
<span
className="flex items-center gap-1 font-mono text-[10px] tracking-widest text-amber-400 uppercase"
title={layout.warnings.map((w) => w.message).join("\n")}
data-testid="tech-tree-warnings"
>
<TriangleAlertIcon className="size-3" />
{layout.warnings.length} TIMELINE WARNING{layout.warnings.length === 1 ? "" : "S"}
</span>
) : null}
{connectError ? (
<span
className="font-mono text-[10px] tracking-widest text-red-400 uppercase"
data-testid="tech-tree-connect-error"
>
{connectError}
</span>
) : null}
{saveStatus ? (
<span
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
data-testid="tech-tree-save-status"
>
{saveStatus}
</span>
) : null}
{data.canUpdate ? (
<Button
size="sm"
variant="ghost"
onClick={handleSavePositions}
title="Save the current positions of every node so the layout stays the same on your next visit"
data-testid="tech-tree-save-positions"
className={TACTICAL_BUTTON}
>
<SaveIcon className="size-3.5" />
Save positions
</Button>
) : null}
{data.canUpdate && hasManualPositions ? (
<Button
size="sm"
variant="ghost"
onClick={handleAutoArrange}
disabled={arranging}
title="Clear manual node positions and restore the automatic layout"
data-testid="tech-tree-auto-arrange"
className={TACTICAL_BUTTON}
>
<LayoutGridIcon className="size-3.5" />
{arranging ? "Arranging..." : "Auto-arrange"}
</Button>
) : null}
{data.canCreate ? (
<Button
size="sm"
variant="ghost"
onClick={() => setCreateOpen(true)}
data-testid="tech-tree-create"
className={`${TACTICAL_BUTTON} border-[#22d3ee]/40 text-[#67e8f9] hover:border-[#22d3ee]/70 hover:bg-[#22d3ee]/10 hover:text-[#a5f3fc]`}
>
<PlusIcon className="size-3.5" />
New Technology
</Button>
) : null}
{data.canUpdate ? (
<span className="hidden font-mono text-[9px] tracking-widest text-white/35 uppercase lg:inline">
DRAG NODE EDGE TO LINK A PREREQUISITE · SELECT AN EDGE + BACKSPACE TO REMOVE
</span>
) : null}
</div>
{/* Empty state */}
{techs.length === 0 ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="pointer-events-auto rounded-none border border-white/15 bg-black/70 px-6 py-5 text-center backdrop-blur-sm">
<p className="font-mono text-[10px] tracking-widest text-white/50 uppercase">
NO TECHNOLOGIES ON RECORD
</p>
<p className="mt-1 text-sm text-white/70">
Create the first technology to start the tree.
</p>
{data.canCreate ? (
<Button
className={`${TACTICAL_BUTTON} mt-3`}
size="sm"
variant="ghost"
onClick={() => setCreateOpen(true)}
>
<PlusIcon className="size-3.5" />
New Technology
</Button>
) : null}
</div>
</div>
) : null}
{/* Preview / edit panel */}
{selectedTech ? (
<TechPreviewPanel
tech={selectedTech}
categories={categories}
resources={resources}
allTechs={techs}
canUpdate={data.canUpdate}
canCreateCategories={data.canCreateCategories}
canSetFinalApproval={data.canSetFinalApproval}
warnings={selectedWarnings}
onClose={() => setSelectedId(null)}
onEdit={() => router.push(`/admin/collections/technologies/${selectedTech.id}`)}
onSaved={() => {
void refresh({ freeze: true });
}}
/>
) : null}
<QuickCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
categories={categories}
canCreateCategories={data.canCreateCategories}
onCreated={handleCreated}
/>
</div>
);
}
export function TechTreeCanvas({ data }: { data: TechTreeData }) {
return (
<ReactFlowProvider>
<CanvasInner data={data} />
</ReactFlowProvider>
);
}

View file

@ -0,0 +1,151 @@
import { NavHamburger, NavWrapper } from "@payloadcms/next/client";
import { Logout } from "@payloadcms/ui";
import { RenderServerComponent } from "@payloadcms/ui/elements/RenderServerComponent";
import { EntityType, groupNavItems, type EntityToGroup } from "@payloadcms/ui/shared";
import { PREFERENCE_KEYS } from "payload/shared";
import type { PayloadRequest, SanitizedPermissions, ServerProps } from "payload";
import React from "react";
import { TechTreeNavClient, type TechTreeNavGroup, type TechTreeNavEntity } from "./TechTreeNavClient";
const baseClass = "nav";
type NavProps = ServerProps & { req?: PayloadRequest };
/**
* Replaces the default admin Nav so a "Tech Tree" entry can be injected into
* the Intelligence group (the group containing the technologies collection).
* Everything else mirrors Payload's DefaultNav: groups from visible entities,
* nav preferences, before/after components, and the logout control. The
* Tech Tree entry rides along with the technologies collection's visibility,
* so users who cannot see Technologies never see the tree entry either.
*/
export const TechTreeNav = async ({
i18n,
locale,
params,
payload,
permissions,
req,
searchParams,
user,
visibleEntities,
}: NavProps) => {
if (!payload?.config) {
return null;
}
const {
admin: {
components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout },
},
collections,
globals,
} = payload.config;
const visibleCollections = visibleEntities?.collections ?? [];
const visibleGlobals = visibleEntities?.globals ?? [];
const groups: TechTreeNavGroup[] = groupNavItems(
[
...visibleCollections.map((slug) => {
const collection = collections.find((c) => c.slug === slug)!;
return { type: EntityType.collection, entity: collection } satisfies EntityToGroup;
}),
...visibleGlobals.map((slug) => {
const global = globals.find((g) => g.slug === slug)!;
return { type: EntityType.global, entity: global } satisfies EntityToGroup;
}),
],
permissions ?? ({} as SanitizedPermissions),
i18n,
).map((group): TechTreeNavGroup => {
const techIndex = group.entities.findIndex(
(entity) => entity.type === EntityType.collection && entity.slug === "technologies",
);
if (techIndex === -1) return group;
const entities: TechTreeNavEntity[] = [...group.entities];
entities.splice(techIndex + 1, 0, {
slug: "tech-tree",
label: "Tech Tree",
type: "custom",
href: "/tech-tree",
});
return { ...group, entities };
});
let navPreferences: { groups?: { [key: string]: { open: boolean } } } | null = null;
if (req?.user?.collection) {
const prefs = await req.payload.find({
collection: "payload-preferences",
depth: 0,
limit: 1,
pagination: false,
req,
where: {
and: [
{ key: { equals: PREFERENCE_KEYS.NAV } },
{ "user.relationTo": { equals: req.user.collection } },
{ "user.value": { equals: req?.user?.id } },
],
},
});
navPreferences = (prefs?.docs?.[0]?.value as typeof navPreferences) ?? null;
}
const serverProps = { i18n, locale, params, payload, permissions, searchParams, user };
const clientProps = { documentSubViewType: undefined, viewType: undefined };
const LogoutComponent = RenderServerComponent({
clientProps,
Component: logout?.Button,
Fallback: Logout,
importMap: payload.importMap,
serverProps,
});
const RenderedBeforeNav = RenderServerComponent({
clientProps,
Component: beforeNav,
importMap: payload.importMap,
serverProps,
});
const RenderedBeforeNavLinks = RenderServerComponent({
clientProps,
Component: beforeNavLinks,
importMap: payload.importMap,
serverProps,
});
const RenderedAfterNavLinks = RenderServerComponent({
clientProps,
Component: afterNavLinks,
importMap: payload.importMap,
serverProps,
});
const RenderedAfterNav = RenderServerComponent({
clientProps,
Component: afterNav,
importMap: payload.importMap,
serverProps,
});
return (
<NavWrapper baseClass={baseClass}>
{RenderedBeforeNav}
<nav className={`${baseClass}__wrap`}>
{RenderedBeforeNavLinks}
<TechTreeNavClient groups={groups} navPreferences={navPreferences} />
{RenderedAfterNavLinks}
<div className={`${baseClass}__controls`}>{LogoutComponent}</div>
</nav>
{RenderedAfterNav}
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<NavHamburger baseClass={baseClass} />
</div>
</div>
</NavWrapper>
);
};
export default TechTreeNav;

View file

@ -0,0 +1,103 @@
"use client";
import { Link, NavGroup, useConfig, useTranslation } from "@payloadcms/ui";
import { EntityType } from "@payloadcms/ui/shared";
import { usePathname } from "next/navigation.js";
import { formatAdminURL } from "payload/shared";
import React, { Fragment } from "react";
/**
* A nav entity: either a standard Payload entity (collection/global) or a
* custom view entry injected by TechTreeNav (type "custom", href given).
*/
export type TechTreeNavEntity = {
href?: `/${string}`;
label: unknown;
slug: string;
type: EntityType | "custom";
};
export type TechTreeNavGroup = {
entities: TechTreeNavEntity[];
label: string;
};
type Props = {
groups: TechTreeNavGroup[];
navPreferences?: {
groups?: { [key: string]: { open: boolean } };
} | null;
};
const baseClass = "nav";
export const TechTreeNavClient = ({ groups, navPreferences }: Props) => {
const pathname = usePathname();
const {
config: { routes, admin: adminConfig },
} = useConfig();
const { i18n } = useTranslation();
const adminRoute = routes.admin;
return (
<Fragment>
{groups.map((group, groupKey) => {
const { entities, label } = group;
return (
<NavGroup
key={groupKey}
isOpen={navPreferences?.groups?.[label]?.open}
label={label}
>
{entities.map((entity, i) => {
const { label: entityLabel, slug, type } = entity;
let href: string | undefined;
let id: string | undefined;
if (type === "custom" && entity.href) {
href = formatAdminURL({ adminRoute, path: entity.href });
id = `nav-${slug}`;
} else if (type === EntityType.collection) {
href = formatAdminURL({ adminRoute, path: `/collections/${slug}` });
id = `nav-${slug}`;
} else if (type === EntityType.global) {
href = formatAdminURL({ adminRoute, path: `/globals/${slug}` });
id = `nav-global-${slug}`;
}
if (!href) return null;
const isActive =
pathname === href ||
(pathname.startsWith(href) && ["/", undefined].includes(pathname[href.length]));
const Label = (
<Fragment>
{isActive && <div className={`${baseClass}__link-indicator`} />}
<span className={`${baseClass}__link-label`}>
{typeof entityLabel === "string" ? entityLabel : String(entityLabel)}
</span>
</Fragment>
);
if (isActive) {
return (
<div className={`${baseClass}__link`} id={id} key={i}>
{Label}
</div>
);
}
return (
<Link className={`${baseClass}__link`} href={href} id={id} key={i} prefetch={false}>
{Label}
</Link>
);
})}
</NavGroup>
);
})}
</Fragment>
);
};

View file

@ -0,0 +1,104 @@
import { DefaultTemplate } from "@payloadcms/next/templates";
import type { AdminViewServerProps } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { TechTreeCanvas, type TechTreeData } from "./TechTreeCanvas";
import { mapCategoryDoc, mapTechDoc } from "./mappers";
/**
* Root admin view at /admin/tech-tree. Custom views are public by default, so
* this gates access itself: logged-in users holding technologies:read (or the
* Intelligence division qualification, mirroring the technologies collection's
* scoped admin-panel access).
*/
export async function TechTreeView({
initPageResult,
params,
searchParams,
}: AdminViewServerProps) {
const {
req: { payload, user },
} = initPageResult;
if (!user) {
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={user ?? undefined}
visibleEntities={initPageResult.visibleEntities}
>
<div className="p-8">
<p className="text-sm text-white/70">You must be logged in to view the Tech Tree.</p>
</div>
</DefaultTemplate>
);
}
const hasRead = await hasPermission(payload, user, "technologies:read");
const intelQualified = await hasIntelligenceQualification(payload, user);
if (!hasRead && !intelQualified) {
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={user ?? undefined}
visibleEntities={initPageResult.visibleEntities}
>
<div className="p-8">
<p className="text-sm text-white/70">
You do not have permission to view the Tech Tree.
</p>
</div>
</DefaultTemplate>
);
}
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
payload.find({ collection: "technologies", depth: 0, limit: 0, sort: "name" }),
payload.find({ collection: "tech-categories", depth: 0, limit: 0, sort: "name" }),
payload.find({ collection: "resources", depth: 0, limit: 0, sort: "name" }),
]);
const data: TechTreeData = {
techs: techDocs.docs.map((doc) => mapTechDoc(doc as unknown as Record<string, unknown>)),
categories: categoryDocs.docs.map((doc) =>
mapCategoryDoc(doc as unknown as Record<string, unknown>),
),
resources: resourceDocs.docs.map((doc) => ({ id: doc.id, name: doc.name })),
canCreate:
(await hasPermission(payload, user, "technologies:create")) || intelQualified,
canCreateCategories:
(await hasPermission(payload, user, "tech-categories:create")) || intelQualified,
canUpdate: (await hasPermission(payload, user, "technologies:update")) || intelQualified,
canSetFinalApproval: await hasPermission(payload, user, "system:admin-access"),
};
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={user}
visibleEntities={initPageResult.visibleEntities}
>
<TechTreeCanvas data={data} />
</DefaultTemplate>
);
}
export default TechTreeView;

View file

@ -0,0 +1,99 @@
import type { TreeTech } from "./types";
/**
* Progression-path palette: high-contrast on the dark canvas, distinct from the
* amber warning and red error accents used elsewhere in the tree.
*/
export const PATH_PALETTE = [
"#22d3ee", // cyan
"#a78bfa", // violet
"#34d399", // emerald
"#f472b6", // pink
"#38bdf8", // sky
"#a3e635", // lime
"#fb923c", // orange
"#e879f9", // fuchsia
];
export type EdgeRef = { id: string; source: number; target: number };
export type EdgeVisual = { color: string; stepPosition: number; pathRoot: number };
/**
* Visual treatment for each prerequisite edge:
*
* - **color**: the edge inherits the color of the progression path it belongs
* to, identified by the root technology the chain grows from (the lowest-id
* root reachable through the source's prerequisites). Separate chains read as
* separate colors; one chain keeps one color end to end.
* - **stepPosition**: smoothstep edges turn at the midpoint by default, so
* edges from DIFFERENT sources crossing the same column gap stack their
* vertical runs on top of each other. Each source in a gap gets its own lane
* (spread across 20%..80% of the horizontal run). Edges from the SAME source
* share a lane on purpose: they read as one bus fanning out from a parent.
*/
export function edgeVisuals(
techs: TreeTech[],
edges: EdgeRef[],
positions: Map<number, { x: number; y: number }>,
columnStep: number,
): Map<string, EdgeVisual> {
const byId = new Map(techs.map((t) => [t.id, t]));
const parentsOf = (id: number): number[] => {
const tech = byId.get(id);
if (!tech) return [];
return [...new Set([...tech.prereqAnd, ...tech.prereqOr])].filter(
(p) => p !== id && byId.has(p),
);
};
// Root of the progression path (cycle-safe, memoized).
const rootMemo = new Map<number, number>();
const rootOf = (id: number, stack: Set<number> = new Set()): number => {
const cached = rootMemo.get(id);
if (cached !== undefined) return cached;
if (stack.has(id)) return id;
stack.add(id);
const parents = parentsOf(id);
const root = parents.length === 0 ? id : Math.min(...parents.map((p) => rootOf(p, stack)));
stack.delete(id);
rootMemo.set(id, root);
return root;
};
const roots = [...new Set(edges.map((e) => rootOf(e.source)))].sort((a, b) => a - b);
const colorOfRoot = new Map(roots.map((r, i) => [r, PATH_PALETTE[i % PATH_PALETTE.length]]));
// Lanes: group distinct sources by the column gap they leave from.
const sourcesByGap = new Map<number, number[]>();
for (const edge of edges) {
const pos = positions.get(edge.source);
if (!pos) continue;
const gap = Math.round(pos.x / columnStep);
const list = sourcesByGap.get(gap) ?? [];
if (!list.includes(edge.source)) list.push(edge.source);
sourcesByGap.set(gap, list);
}
const laneOf = new Map<number, number>();
for (const sources of sourcesByGap.values()) {
sources.sort((a, b) => {
const pa = positions.get(a)!;
const pb = positions.get(b)!;
return pa.y - pb.y || a - b;
});
const n = sources.length;
sources.forEach((source, i) => {
laneOf.set(source, n === 1 ? 0.5 : 0.2 + (0.6 * i) / (n - 1));
});
}
const visuals = new Map<string, EdgeVisual>();
for (const edge of edges) {
const pathRoot = rootOf(edge.source);
visuals.set(edge.id, {
color: colorOfRoot.get(pathRoot) ?? PATH_PALETTE[0],
stepPosition: laneOf.get(edge.source) ?? 0.5,
pathRoot,
});
}
return visuals;
}

View file

@ -0,0 +1,45 @@
import { computeLayout } from "./layout";
import type { TreeCategory, TreeTech } from "./types";
export type CanvasPosition = { x: number; y: number };
/**
* Layout stability contract: a data change (new prerequisite, removed edge,
* panel save, new technology) must not move cards on screen. Only dragging and
* "Auto-arrange" move things.
*
* Given where every card currently sits and the technologies AFTER the change,
* this returns a pin for each technology the ordered layout would relocate, at
* its current on-screen position. Writing those pins as `treePosition` keeps
* the canvas visually still; Auto-arrange later clears them.
*
* Technologies with no current position (e.g. just created) are skipped: they
* take their ordered slot.
*/
export function pinsToFreeze(
current: Map<number, CanvasPosition>,
nextTechs: TreeTech[],
categories: TreeCategory[],
exclude: Set<number> = new Set(),
): Map<number, CanvasPosition> {
const next = computeLayout(nextTechs, categories);
const pins = new Map<number, CanvasPosition>();
for (const node of next.nodes) {
if (exclude.has(node.techId)) continue;
const before = current.get(node.techId);
if (!before) continue;
if (Math.abs(node.x - before.x) > 1 || Math.abs(node.y - before.y) > 1) {
pins.set(node.techId, { x: Math.round(before.x), y: Math.round(before.y) });
}
}
return pins;
}
/** Apply pins to a technology list (returns new objects only where pinned). */
export function applyPins(techs: TreeTech[], pins: Map<number, CanvasPosition>): TreeTech[] {
if (pins.size === 0) return techs;
return techs.map((t) => {
const pin = pins.get(t.id);
return pin ? { ...t, treePosition: pin } : t;
});
}

View file

@ -0,0 +1,262 @@
import {
BAND_PADDING_BOTTOM,
BAND_PADDING_TOP,
BAND_PADDING_X,
COLUMN_STEP,
NODE_HEIGHT,
NODE_WIDTH,
ROW_STEP,
type CategoryBandLayout,
type LayoutWarning,
type TechLayoutNode,
type TechTreeLayout,
type TreeCategory,
type TreeTech,
} from "./types";
/**
* How many rows a column holds before placement spills into the next column.
* Keeps a large root pool (or a huge category) readable instead of one
* 8000px-tall strip.
*/
export const MAX_ROWS_PER_COLUMN = 10;
/**
* Propagating left-to-right tree layout.
*
* The rule that gives the tree its shape: a technology WITH prerequisites is
* placed one column to the right of its parents, at their average row. So
* linking A -> B makes B appear immediately to the right of A (short, local
* edge) and B's own dependents continue further right. Nothing is teleported
* to a distant column just because its depth changed.
*
* - Technologies in a category are pinned to that category's column (the
* category contract), stacked in depth/name order.
* - Uncategorized technologies with no prerequisites form a compact root grid
* starting after the category columns.
* - Uncategorized technologies with prerequisites follow their parents inside
* the uncategorized zone.
* - Collisions resolve by searching rows outward from the desired row, then
* spilling into the next column.
* - A manual `treePosition` always wins; category bands are the bounding box of
* their members' final positions.
*/
export function computeLayout(techs: TreeTech[], categories: TreeCategory[]): TechTreeLayout {
const byId = new Map<number, TreeTech>(techs.map((t) => [t.id, t]));
const parentsOf = (tech: TreeTech): number[] => {
const ids = [...tech.prereqAnd, ...tech.prereqOr];
return [...new Set(ids)].filter((id) => byId.has(id) && id !== tech.id);
};
// Longest-path depth, used only for placement ORDER (parents before children)
// and for the forward-edge warnings. Cycle-safe.
const depthById = new Map<number, number>();
const inStack = new Set<number>();
const depthOf = (tech: TreeTech): number => {
const cached = depthById.get(tech.id);
if (cached !== undefined) return cached;
if (inStack.has(tech.id)) return 0;
inStack.add(tech.id);
const parents = parentsOf(tech);
const d = parents.length === 0 ? 0 : 1 + Math.max(...parents.map((id) => depthOf(byId.get(id)!)));
inStack.delete(tech.id);
depthById.set(tech.id, d);
return d;
};
techs.forEach(depthOf);
const sortedCategories = [...categories].sort(
(a, b) => a.position - b.position || a.name.localeCompare(b.name),
);
const columnOfCategory = new Map<number, number>(sortedCategories.map((c, i) => [c.id, i]));
const zoneStart = sortedCategories.length;
const isCategorized = (tech: TreeTech): boolean =>
tech.categoryId !== null && columnOfCategory.has(tech.categoryId);
const MAX_ROWS = MAX_ROWS_PER_COLUMN;
const occupancy = new Map<string, number>();
const positions = new Map<number, { col: number; row: number }>();
const cellKey = (col: number, row: number) => `${col}:${row}`;
const unbounded = Number.POSITIVE_INFINITY;
const findFreeRow = (col: number, desiredRow: number, maxRows: number): number | null => {
// Unbounded placement (dependents) may also grow UPWARDS, so a technology
// linked from a full column still lands right beside its parent.
const allowNegative = maxRows === unbounded;
for (let offset = 0; offset < 500; offset += 1) {
const down = desiredRow + offset;
if (down < maxRows && !occupancy.has(cellKey(col, down))) return down;
const up = desiredRow - offset;
if ((allowNegative || up >= 0) && !occupancy.has(cellKey(col, up))) return up;
}
return null;
};
/**
* `maxRows` caps how tall a column may grow before placement spills right.
* Grid-style placement (roots, category members) is capped; dependents are
* NOT, so a technology linked from a full column still lands immediately
* beside its parent instead of being pushed to a distant column.
*/
const placeNear = (
tech: TreeTech,
desiredCol: number,
desiredRow: number,
maxRows: number = MAX_ROWS,
) => {
let col = Math.max(0, desiredCol);
for (let attempt = 0; attempt < 200; attempt += 1) {
const row = findFreeRow(col, Math.max(0, desiredRow), maxRows);
if (row !== null) {
occupancy.set(cellKey(col, row), tech.id);
positions.set(tech.id, { col, row });
return { col, row };
}
col += 1; // column full: spill to the right
}
occupancy.set(cellKey(col, 0), tech.id);
positions.set(tech.id, { col, row: 0 });
return { col, row: 0 };
};
const byDepthThenName = (a: TreeTech, b: TreeTech) =>
depthOf(a) - depthOf(b) || a.name.localeCompare(b.name);
// 1) Categorized technologies are pinned to their category's column.
const nextRowByColumn = new Map<number, number>();
for (const tech of techs.filter(isCategorized).sort(byDepthThenName)) {
const col = columnOfCategory.get(tech.categoryId!)!;
const desired = nextRowByColumn.get(col) ?? 0;
const placed = placeNear(tech, col, desired);
nextRowByColumn.set(col, placed.row + 1);
}
// 2) Uncategorized roots: a catalog grid starting after the categories.
//
// Slots come from each tech's rank among ALL uncategorized technologies, not
// among the current roots, so a tech that becomes a dependent leaves its
// catalog slot EMPTY instead of shifting every other root. That is what keeps
// connecting two nodes from re-shuffling the whole grid.
const catalogRank = new Map<number, number>();
[...techs]
.filter((tech) => !isCategorized(tech))
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((tech, index) => catalogRank.set(tech.id, index));
for (const tech of techs) {
if (isCategorized(tech) || parentsOf(tech).length > 0) continue;
const slot = catalogRank.get(tech.id) ?? 0;
placeNear(tech, zoneStart + Math.floor(slot / MAX_ROWS), slot % MAX_ROWS);
}
// 3) Uncategorized dependents: one column right of their parents, at their row.
const dependents = techs
.filter((tech) => !isCategorized(tech) && parentsOf(tech).length > 0)
.sort(byDepthThenName);
for (const tech of dependents) {
const parentPositions = parentsOf(tech)
.map((id) => positions.get(id))
.filter((p): p is { col: number; row: number } => Boolean(p));
if (parentPositions.length === 0) {
// Unplaced parents (cycle): park it in the uncategorized zone.
placeNear(tech, zoneStart, 0);
continue;
}
const desiredCol = Math.max(zoneStart, Math.max(...parentPositions.map((p) => p.col)) + 1);
const desiredRow = Math.round(
parentPositions.reduce((sum, p) => sum + p.row, 0) / parentPositions.length,
);
placeNear(tech, desiredCol, desiredRow, unbounded);
}
// Manual overrides win over the automatic grid.
const finalPosition = (tech: TreeTech): { x: number; y: number } => {
const manual = tech.treePosition;
if (manual && Number.isFinite(manual.x) && Number.isFinite(manual.y)) {
return { x: manual.x, y: manual.y };
}
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
return { x: placed.col * COLUMN_STEP, y: placed.row * ROW_STEP };
};
const nodes: TechLayoutNode[] = techs.map((tech) => {
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
const { x, y } = finalPosition(tech);
return { techId: tech.id, column: placed.col, row: placed.row, x, y };
});
// Forward-edge warnings: a prerequisite placed to the RIGHT of its dependent.
const columnById = new Map(nodes.map((n) => [n.techId, n.column]));
const warnings: LayoutWarning[] = [];
for (const tech of techs) {
for (const parentId of parentsOf(tech)) {
const parent = byId.get(parentId)!;
if ((columnById.get(parentId) ?? 0) > (columnById.get(tech.id) ?? 0)) {
warnings.push({
techId: tech.id,
message: `Prerequisite "${parent.name}" sits to the right of "${tech.name}" on the timeline.`,
});
break;
}
}
}
// Category bands: bounding box of each group's final node positions.
const bandFor = (
key: string,
label: string,
color: string | null,
position: number | null,
members: TreeTech[],
): CategoryBandLayout | null => {
if (members.length === 0) return null;
const coords = members.map((m) => finalPosition(m));
const minX = Math.min(...coords.map((c) => c.x));
const maxX = Math.max(...coords.map((c) => c.x));
const minY = Math.min(...coords.map((c) => c.y));
const maxY = Math.max(...coords.map((c) => c.y));
return {
key,
label,
color,
x: minX - BAND_PADDING_X,
y: minY - BAND_PADDING_TOP,
width: maxX + NODE_WIDTH - minX + BAND_PADDING_X * 2,
height: maxY + NODE_HEIGHT - minY + BAND_PADDING_TOP + BAND_PADDING_BOTTOM,
position,
};
};
const bands: CategoryBandLayout[] = [];
for (const category of sortedCategories) {
const members = techs.filter((t) => t.categoryId === category.id);
const band = bandFor(
`cat-${category.id}`,
category.name,
category.color ?? null,
category.position,
members,
);
if (band) bands.push(band);
}
const uncategorizedBand = bandFor(
"uncategorized",
"Uncategorized",
null,
null,
techs.filter((t) => !isCategorized(t)),
);
if (uncategorizedBand) bands.push(uncategorizedBand);
const maxColumn = nodes.reduce((max, n) => Math.max(max, n.column), 0);
const maxRow = nodes.reduce((max, n) => Math.max(max, n.row), 0);
return {
nodes,
bands,
warnings,
columns: nodes.length === 0 ? 0 : maxColumn + 1,
rows: nodes.length === 0 ? 0 : maxRow + 1,
};
}

View file

@ -0,0 +1,67 @@
import type { TreeCategory, TreeTech } from "./types";
/**
* Project a `technologies` REST/local API doc (depth 0: relationships are
* plain ids) into the lightweight TreeTech shape used by the tree.
*/
export function mapTechDoc(doc: Record<string, unknown>): TreeTech {
const requirements = doc.requirements as { prerequisiteTechnologies?: unknown[] } | null;
const rows = Array.isArray(requirements?.prerequisiteTechnologies)
? (requirements!.prerequisiteTechnologies as Record<string, unknown>[])
: [];
const prereqAnd: number[] = [];
const prereqOr: number[] = [];
for (const row of rows) {
const requiresAnd = row?.requiresAnd as { technology?: unknown[] } | undefined;
const requiresOr = row?.requiresOr as { technology?: unknown[] } | undefined;
for (const t of requiresAnd?.technology ?? []) {
if (typeof t === "number") prereqAnd.push(t);
}
for (const t of requiresOr?.technology ?? []) {
if (typeof t === "number") prereqOr.push(t);
}
}
const researchCosts = doc.researchCosts as { resources?: unknown[] } | null;
const costRows = Array.isArray(researchCosts?.resources)
? (researchCosts!.resources as Record<string, unknown>[])
: [];
const resourceCosts = costRows
.map((row) => ({
resource: row?.resource as number,
amount: typeof row?.amount === "number" ? row.amount : 0,
}))
.filter((row) => typeof row.resource === "number");
const manualPosition = doc.treePosition as { x?: unknown; y?: unknown } | null | undefined;
const treePosition =
manualPosition &&
typeof manualPosition.x === "number" &&
typeof manualPosition.y === "number" &&
Number.isFinite(manualPosition.x) &&
Number.isFinite(manualPosition.y)
? { x: manualPosition.x, y: manualPosition.y }
: null;
return {
id: doc.id as number,
name: String(doc.name ?? ""),
type: doc.type === "asset" ? "asset" : "upgrade",
approvalStatus: String(doc.approvalStatus ?? "in_progress"),
summary: String(doc.summary ?? ""),
categoryId: typeof doc.category === "number" ? doc.category : null,
prereqAnd: [...new Set(prereqAnd)],
prereqOr: [...new Set(prereqOr)].filter((id) => !prereqAnd.includes(id)),
resourceCosts,
treePosition,
};
}
/** Project a `tech-categories` doc into the TreeCategory shape. */
export function mapCategoryDoc(doc: Record<string, unknown>): TreeCategory {
return {
id: doc.id as number,
name: String(doc.name ?? ""),
position: typeof doc.position === "number" ? doc.position : 1,
color: typeof doc.color === "string" && doc.color ? doc.color : null,
};
}

View file

@ -0,0 +1,114 @@
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 };
}

View file

@ -0,0 +1,74 @@
/**
* Shared shapes for the Tech Tree admin view. These are lightweight
* serializable projections of the `technologies` and `tech-categories`
* documents, built server-side by TechTreeView and consumed by the canvas.
*/
export type TreeTech = {
id: number;
name: string;
type: "asset" | "upgrade";
approvalStatus: string;
summary: string;
categoryId: number | null;
/** Union of all AND-row prerequisite technology ids. */
prereqAnd: number[];
/** Union of all OR-row prerequisite technology ids. */
prereqOr: number[];
/** Resource costs: resource id + amount. */
resourceCosts: { resource: number; amount: number }[];
/** Manual canvas position (dragged node); null = automatic layout. */
treePosition: { x: number; y: number } | null;
};
export type TreeCategory = {
id: number;
name: string;
position: number;
color: string | null;
};
export type LayoutWarning = {
techId: number;
message: string;
};
export type TechLayoutNode = {
techId: number;
column: number;
row: number;
x: number;
y: number;
};
export type CategoryBandLayout = {
/** tech-categories id, or "uncategorized" for the trailing auto zone. */
key: string;
label: string;
color: string | null;
x: number;
y: number;
width: number;
height: number;
position: number | null;
};
export type TechTreeLayout = {
nodes: TechLayoutNode[];
bands: CategoryBandLayout[];
warnings: LayoutWarning[];
columns: number;
rows: number;
};
/** Canvas geometry (px). */
export const NODE_WIDTH = 236;
export const NODE_HEIGHT = 148;
export const COLUMN_GAP = 132;
export const ROW_GAP = 36;
export const BAND_PADDING_X = 20;
export const BAND_PADDING_TOP = 64;
export const BAND_PADDING_BOTTOM = 28;
export const COLUMN_STEP = NODE_WIDTH + COLUMN_GAP;
export const ROW_STEP = NODE_HEIGHT + ROW_GAP;

View file

@ -45,6 +45,7 @@ import { Shipments } from "@/collections/logistics/Shipments";
import { Assignments } from "@/collections/users/Assignments";
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
import { Technologies } from "@/collections/intelligence/Technologies";
import { TechCategories } from "@/collections/intelligence/TechCategories";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
import { GameHardResources } from "@/collections/game/GameHardResources";
@ -275,6 +276,7 @@ const collections = [
Campaigns,
Factions,
Technologies,
TechCategories,
// Logistics
Assets,
@ -415,6 +417,13 @@ export default buildConfig({
Logo: "@/components/graphics/AppLogo",
Icon: "@/components/graphics/AppIcon",
},
Nav: "@/components/admin/tech-tree/TechTreeNav",
views: {
techTree: {
path: "/tech-tree",
Component: "@/components/admin/tech-tree/TechTreeView",
},
},
},
},
globals: [GameRules, Shims],