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:
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") {
|
||||
|
||||
Reference in new issue
Block a user