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,127 @@
|
||||
import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
|
||||
|
||||
// Layout math shared by the 3D scene, the SVG fallback and the tests.
|
||||
// 1 unit = 100 mm; x = length, y = height (stack grows on +y from 0), z = width.
|
||||
// The model is centred at x = z = 0.
|
||||
|
||||
export type Vec3 = [number, number, number];
|
||||
|
||||
export interface PartBox {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
kind: HivePartKind;
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
/** Stack index; an insert carries the index of its host. */
|
||||
stackIndex: number;
|
||||
}
|
||||
|
||||
export interface FrameBox {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string;
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
}
|
||||
|
||||
export interface HiveLayout {
|
||||
parts: PartBox[];
|
||||
inserts: PartBox[];
|
||||
frames: FrameBox[];
|
||||
bounds: { w: number; h: number; d: number };
|
||||
}
|
||||
|
||||
const UNIT_MM = 100;
|
||||
const ROOF_OVERLAP_MM = 35;
|
||||
const BOX_WALL_MM = 20;
|
||||
const FRAME_THICKNESS_MM = 25;
|
||||
const FRAME_HEIGHT_MARGIN_MM = 10;
|
||||
const FLOOR_INSERT_LIFT_MM = 5;
|
||||
|
||||
const mm = (value: number): number => value / UNIT_MM;
|
||||
|
||||
function sizeOf(type: CatalogPartType): Vec3 {
|
||||
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
|
||||
}
|
||||
|
||||
function insertBox(
|
||||
insert: HivePart,
|
||||
type: CatalogPartType,
|
||||
host: PartBox,
|
||||
): PartBox {
|
||||
const size = sizeOf(type);
|
||||
const hostBottom = host.center[1] - host.size[1] / 2;
|
||||
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
|
||||
// bottom + 5 mm, y is always a centre); every other insert
|
||||
// (the EVAPORATOR) stands on the bottom of the box it sits in.
|
||||
const centerY =
|
||||
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
|
||||
return {
|
||||
id: insert.id,
|
||||
partTypeId: insert.partTypeId,
|
||||
kind: type.kind,
|
||||
center: [0, centerY, 0],
|
||||
size,
|
||||
stackIndex: host.stackIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
|
||||
const frames = part.frames ?? [];
|
||||
if (frames.length === 0) return [];
|
||||
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
|
||||
const pitch = inner / frames.length;
|
||||
const size: Vec3 = [
|
||||
mm(FRAME_THICKNESS_MM),
|
||||
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
|
||||
box.size[2] - 2 * mm(BOX_WALL_MM),
|
||||
];
|
||||
return frames.map((frame, index) => ({
|
||||
boxId: part.id,
|
||||
slot: frame.slot,
|
||||
type: frame.type,
|
||||
...(frame.name !== undefined ? { name: frame.name } : {}),
|
||||
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
|
||||
size,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Pure layout of a hive state in 3D units (1 = 100 mm). Unknown part types are skipped. */
|
||||
export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
|
||||
const parts: PartBox[] = [];
|
||||
const inserts: PartBox[] = [];
|
||||
const frames: FrameBox[] = [];
|
||||
let top = 0;
|
||||
let maxTop = 0;
|
||||
let width = 0;
|
||||
let depth = 0;
|
||||
|
||||
state.stack.forEach((part, stackIndex) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return;
|
||||
const size = sizeOf(type);
|
||||
const bottom = type.kind === "ROOF" ? top - mm(ROOF_OVERLAP_MM) : top;
|
||||
const box: PartBox = {
|
||||
id: part.id,
|
||||
partTypeId: part.partTypeId,
|
||||
kind: type.kind,
|
||||
center: [0, bottom + size[1] / 2, 0],
|
||||
size,
|
||||
stackIndex,
|
||||
};
|
||||
parts.push(box);
|
||||
top = bottom + size[1];
|
||||
maxTop = Math.max(maxTop, top);
|
||||
width = Math.max(width, size[0]);
|
||||
depth = Math.max(depth, size[2]);
|
||||
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const insertType = catalog.partTypes.get(insert.partTypeId);
|
||||
if (insertType) inserts.push(insertBox(insert, insertType, box));
|
||||
}
|
||||
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
|
||||
});
|
||||
|
||||
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
|
||||
}
|
||||
Reference in new issue
Block a user