feat: holographic 3D hive scene and Futtertasche (Beute L3, first cut)

- 3D dependencies (three, react-three-fiber, drei, postprocessing,
  motion), exact versions, supply-chain checked; scene loaded only on
  demand on the colony page (283 kB gzip), SVG stays as fallback
- glass hologram per owner design 2026-10-16: true Liebig Zander
  shapes (grips, rebate, Wanderboden foot), cool blue edge glow,
  click any part -> it flies to the front while the rest stays as a
  faint ghost; focus shows content (feed levels, Bausperre, pockets);
  frames rise on hover and take their type look (colour + comb pattern)
- full-bleed card with frosted overlays and glass buttons
- Futtertasche (FRAME_FEEDER): new part kind occupying frame slots
  with a fill level, catalog row, validator rules in both ports,
  outline, sheets, follow-ups, settings

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent c608f5d405
commit 6e13236d49
100 files changed
+9432 -209

No files matched your search

@@ -3,6 +3,8 @@
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import type { FocusEvent, KeyboardEvent, Ref } from "react";
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
import type { SlotRange } from "@/lib/hive/feeder-slots";
import { partDisplayName } from "@/lib/hive/names";
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
import { useHiveView } from "./use-hive-view";
@@ -26,6 +28,8 @@ interface OutlineNode {
isBox: boolean;
isFloor: boolean;
hasState: boolean;
/** Which add action an `action` node stands for. */
action?: "evaporator" | "frameFeeder";
setSize: number;
position: number;
}
@@ -59,6 +63,58 @@ function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: Hive
}
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
const ADD_FRAME_FEEDER_LABEL = "Futtertasche einsetzen …";
type Child =
| { type: "insert"; part: HivePart; range: SlotRange | null }
| { type: "frame"; frame: NonNullable<HivePart["frames"]>[number] };
/**
* Children of a box in outline order: other inserts (evaporator), then the
* frames and frame feeders by slot. Frames a pocket covers are not listed.
*/
function childrenOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): Child[] {
const inserts = visibleInserts(part, catalog).map((insert) => ({
part: insert,
range: feederRange(insert, catalog.partTypes.get(insert.partTypeId)),
}));
const others: Child[] = inserts.filter((i) => !i.range).map((i) => ({ type: "insert", ...i }));
if (type?.kind !== "BOX") return inserts.map((i) => ({ type: "insert", ...i }));
const pockets = inserts.filter((i) => i.range);
const bySlot: Child[] = [];
for (const frame of part.frames ?? []) {
const pocket = pockets.find((p) => p.range && frame.slot >= p.range.from && frame.slot <= p.range.to);
if (!pocket) bySlot.push({ type: "frame", frame });
else if (pocket.range?.from === frame.slot) bySlot.push({ type: "insert", ...pocket });
}
// A pocket whose slot lies outside the frames still shows up, after the frames.
for (const pocket of pockets) {
if (!bySlot.some((child) => child.type === "insert" && child.part.id === pocket.part.id)) {
bySlot.push({ type: "insert", ...pocket });
}
}
return [...others, ...bySlot];
}
function firstActiveType(catalog: HiveCatalog, kind: CatalogPartType["kind"]): CatalogPartType | undefined {
return [...catalog.partTypes.values()].find((t) => t.kind === kind && t.active);
}
function addActions(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog, canEdit: boolean) {
if (type?.kind !== "BOX" || !canEdit) return [];
const actions: { action: "evaporator" | "frameFeeder"; label: string }[] = [];
const hasEvaporator = (part.inserts ?? []).some(
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR",
);
if (!hasEvaporator && firstActiveType(catalog, "EVAPORATOR")) {
actions.push({ action: "evaporator", label: ADD_EVAPORATOR_LABEL });
}
const feederType = firstActiveType(catalog, "FRAME_FEEDER");
if (feederType && freeFeederStarts(part, type, feederType, catalog).length > 0) {
actions.push({ action: "frameFeeder", label: ADD_FRAME_FEEDER_LABEL });
}
return actions;
}
function buildNodes(
state: HiveState,
@@ -66,18 +122,22 @@ function buildNodes(
expanded: ReadonlySet<string>,
canEdit: boolean,
): OutlineNode[] {
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
const nodes: OutlineNode[] = [];
const topDown = [...state.stack].reverse();
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
const addPart = (
part: HivePart,
level: number,
parentKey: string | null,
position: number,
setSize: number,
range: SlotRange | null = null,
) => {
const type = catalog.partTypes.get(part.partTypeId);
const inserts = visibleInserts(part, catalog);
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
const isBox = type?.kind === "BOX";
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
const children = childrenOf(part, type, catalog);
const actions = addActions(part, type, catalog, canEdit);
const childCount = children.length + actions.length;
const displayName = partDisplayName(state, catalog, part.id);
nodes.push({
key: part.id,
kind: "part",
@@ -85,23 +145,28 @@ function buildNodes(
level,
parentKey,
childCount,
name: partDisplayName(state, catalog, part.id),
name: range ? `F${rangeText(range)} · ${displayName}` : displayName,
hint: hintOf(part, type, catalog),
isBox,
isBox: type?.kind === "BOX",
isFloor: type?.kind === "FLOOR",
hasState: (type?.stateSchema.length ?? 0) > 0,
setSize,
position,
});
if (!expanded.has(part.id)) return;
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
frames.forEach((frame, index) => {
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
children.forEach((child, index) => {
if (child.type === "insert") {
addPart(child.part, level + 1, part.id, index + 1, childCount, child.range);
return;
}
const label = [`R${child.frame.slot}`, FRAME_TYPE_LABELS[child.frame.type], child.frame.name]
.filter(Boolean)
.join(" · ");
nodes.push({
key: `${part.id}#${frame.slot}`,
key: `${part.id}#${child.frame.slot}`,
kind: "frame",
id: part.id,
slot: frame.slot,
slot: child.frame.slot,
level: level + 1,
parentKey: part.id,
childCount: 0,
@@ -111,26 +176,27 @@ function buildNodes(
isFloor: false,
hasState: false,
setSize: childCount,
position: inserts.length + index + 1,
position: index + 1,
});
});
if (hasAddAction) {
actions.forEach((action, index) => {
nodes.push({
key: `${part.id}#add-evaporator`,
key: `${part.id}#add-${action.action}`,
kind: "action",
id: part.id,
action: action.action,
level: level + 1,
parentKey: part.id,
childCount: 0,
name: ADD_EVAPORATOR_LABEL,
name: action.label,
hint: null,
isBox: false,
isFloor: false,
hasState: false,
setSize: childCount,
position: childCount,
position: children.length + index + 1,
});
}
});
};
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
@@ -167,7 +233,8 @@ export interface HiveOutlineProps {
canEdit: boolean;
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
announcement: string;
onEditPart: (id: string) => void;
/** `intent` is set by the box's „Futtertasche einsetzen“ action. */
onEditPart: (id: string, intent?: "frameFeeder") => void;
onEditFrame: (boxId: string, slot: number) => void;
}
@@ -282,7 +349,9 @@ export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEdit
const activateNode = (node: OutlineNode): void => {
if (node.kind === "action") {
if (canEdit) onEditPart(node.id);
if (!canEdit) return;
if (node.action === "frameFeeder") onEditPart(node.id, "frameFeeder");
else onEditPart(node.id);
return;
}
if (node.kind === "frame") {