feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)
- lib/hive: frontend port of the hive domain (ops, validator, facts, layout, follow-ups, describe texts, reason texts) running the backend fixtures, API client - hive card on the colony page (same height as Stammdaten): SVG view, accessible outline tree, frame/part sheets with optimistic save, conflict sheet, history with past states, init from template or another colony, inspection draft mode - follow-up toasts prefilling feeding and treatment forms (treatment end only with an open treatment) - settings page "Beutensysteme" (hive-systems:manage) - NumberStepper decimal steps, reactive motion level hook, holo tokens - spec: owner design decision 2026-10-16 (glass hologram, focus mode) The SVG view is the fallback; the 3D scene follows in L3. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0091b3d28b
commit
c608f5d405
87 files changed
+9493
-43
No files matched your search
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
MAX_OPS,
|
||||
type HiveApplyResult,
|
||||
type HiveCatalog,
|
||||
type HiveFrame,
|
||||
type HiveOp,
|
||||
type HivePart,
|
||||
type HiveState,
|
||||
} from "./types";
|
||||
import { validateHiveState } from "./validate";
|
||||
|
||||
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
|
||||
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
|
||||
|
||||
interface Location {
|
||||
part: HivePart;
|
||||
list: HivePart[];
|
||||
index: number;
|
||||
isInsert: boolean;
|
||||
}
|
||||
|
||||
/** Result of one op on the working copy: a reason string, or null on success. */
|
||||
type OpOutcome = string | null;
|
||||
|
||||
function locate(state: HiveState, id: unknown): Location | null {
|
||||
for (let index = 0; index < state.stack.length; index++) {
|
||||
const part = state.stack[index];
|
||||
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
|
||||
const inserts = part.inserts ?? [];
|
||||
for (let i = 0; i < inserts.length; i++) {
|
||||
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function missing(id: unknown): string {
|
||||
return `MISSING_ID: no part with id ${String(id)}`;
|
||||
}
|
||||
|
||||
function clamp(value: unknown, max: number): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return max;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), max);
|
||||
}
|
||||
|
||||
/** Catalog membership and active check for a part to be added (and its inserts). */
|
||||
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
|
||||
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
|
||||
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const nested = checkAddable(insert, catalog);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deep copy of an incoming part without empty `inserts` arrays. */
|
||||
function normalisedCopy(part: HivePart): HivePart {
|
||||
const copy = structuredClone(part);
|
||||
if (Array.isArray(copy.inserts)) {
|
||||
if (copy.inserts.length === 0) delete copy.inserts;
|
||||
else copy.inserts = copy.inserts.map(normalisedCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
|
||||
|
||||
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
|
||||
const host = locate(state, op.hostId);
|
||||
if (!host || host.isInsert) return missing(op.hostId);
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.list.splice(found.index, 1);
|
||||
if (found.isInsert) {
|
||||
const host = state.stack.find((p) => p.inserts === found.list);
|
||||
if (host && found.list.length === 0) delete host.inserts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
|
||||
const [moved] = state.stack.splice(found.index, 1);
|
||||
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.part.state = { ...found.part.state, [op.key]: op.value };
|
||||
return null;
|
||||
}
|
||||
|
||||
function locateBox(state: HiveState, boxId: unknown): Location | string {
|
||||
const found = locate(state, boxId);
|
||||
if (!found) return missing(boxId);
|
||||
if (found.isInsert || !Array.isArray(found.part.frames)) {
|
||||
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === "string") return box;
|
||||
const frames = box.part.frames as HiveFrame[];
|
||||
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
|
||||
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
|
||||
}
|
||||
// name: undefined keeps the current name, null removes it.
|
||||
const current = frames[op.slot - 1];
|
||||
const name = op.name === undefined ? current.name : (op.name ?? undefined);
|
||||
frames[op.slot - 1] =
|
||||
name === undefined
|
||||
? { slot: op.slot, type: op.type }
|
||||
: { slot: op.slot, type: op.type, name };
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === "string") return box;
|
||||
box.part.frames = structuredClone(op.frames);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (op.label === null) delete found.part.label;
|
||||
else found.part.label = op.label;
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
|
||||
switch (op?.op) {
|
||||
case "addPart":
|
||||
return applyAddPart(state, op, catalog);
|
||||
case "addInsert":
|
||||
return applyAddInsert(state, op, catalog);
|
||||
case "removePart":
|
||||
return applyRemovePart(state, op);
|
||||
case "movePart":
|
||||
return applyMovePart(state, op);
|
||||
case "setState":
|
||||
return applySetState(state, op);
|
||||
case "setFrame":
|
||||
return applySetFrame(state, op);
|
||||
case "setFrames":
|
||||
return applySetFrames(state, op);
|
||||
case "setLabel":
|
||||
return applySetLabel(state, op);
|
||||
default:
|
||||
return "UNKNOWN_OP: unsupported operation";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops in order and validates the whole state after each one.
|
||||
* Never mutates its inputs: it works on a private deep copy.
|
||||
*/
|
||||
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
|
||||
if (ops.length > MAX_OPS) {
|
||||
return {
|
||||
ok: false,
|
||||
opIndex: MAX_OPS,
|
||||
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
|
||||
};
|
||||
}
|
||||
const working = structuredClone(state);
|
||||
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
|
||||
const failure =
|
||||
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
|
||||
if (failure) return { ok: false, opIndex, reason: failure };
|
||||
}
|
||||
return { ok: true, state: working };
|
||||
}
|
||||
Reference in new issue
Block a user