Files
ApiCore/ApiCore-Frontend/src/components/hive/frame-feeder-picker.tsx
T
Nick SzaunigandClaude Opus 5.5 6e13236d49 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]>
2026-10-16 09:12:03 +02:00

114 lines
4.1 KiB
TypeScript

"use client";
import { ChoiceGroup } from "@/components/form/choice-group";
import { feederRange, rangeText } from "@/lib/hive/feeder-slots";
import type { CatalogPartType, HiveFrame, HiveOp, HivePart } from "@/lib/hive/types";
import { StateFieldControl } from "./state-field-control";
import type { FieldDraft } from "./state-field-control";
// „Futtertasche einsetzen“ in the box sheet: choose the position, then the fill
// level. Choosing a position empties the frames it covers in the same batch.
const POCKET_OPTIONS = [
{ value: false, label: "Keine Futtertasche" },
{ value: true, label: "Futtertasche einsetzen" },
];
export interface FrameFeederChoice {
wants: boolean;
/** First slot of the new pocket. */
start: number;
draft: FieldDraft;
}
function rangeAt(start: number, type: CatalogPartType) {
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
}
function coveredSlots(start: number, type: CatalogPartType): number[] {
const range = rangeAt(start, type);
if (!range) return [];
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
}
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
}
/** Ops for the chosen pocket: setFrames only if a covered frame is not empty yet, then addInsert. */
export function frameFeederOps(
boxId: string,
frames: HiveFrame[],
type: CatalogPartType,
start: number,
pocket: HivePart,
): HiveOp[] {
const covered = coveredSlots(start, type);
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
const ops: HiveOp[] = [];
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
return ops;
}
interface FrameFeederPickerProps {
boxId: string;
type: CatalogPartType;
frames: HiveFrame[];
starts: number[];
choice: FrameFeederChoice;
onChange: (choice: FrameFeederChoice) => void;
}
export function FrameFeederPicker({ boxId, type, frames, starts, choice, onChange }: FrameFeederPickerProps) {
const groupId = `part-${boxId}-frame-feeder`;
const covered = coveredSlots(choice.start, type);
const losesFrames = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
const startOptions = starts.map((start) => {
const range = rangeAt(start, type);
const noun = range && range.from !== range.to ? "Plätze" : "Platz";
return { value: start, label: `${noun} ${range ? rangeText(range) : start}` };
});
return (
<div className="flex flex-col gap-3">
<span id={groupId} className="text-sm font-medium">
Futtertasche
</span>
<ChoiceGroup
options={POCKET_OPTIONS}
value={choice.wants}
onChange={(wants) => onChange({ ...choice, wants })}
labelledBy={groupId}
className="grid-cols-2"
/>
{choice.wants ? (
<>
<span id={`${groupId}-slot`} className="text-sm font-medium">
Position
</span>
<ChoiceGroup
options={startOptions}
value={choice.start}
onChange={(start) => onChange({ ...choice, start })}
labelledBy={`${groupId}-slot`}
className="grid-cols-3"
/>
{losesFrames ? (
<p className="text-sm text-muted-foreground">Rähmchen auf diesen Plätzen werden zu „leer“.</p>
) : null}
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`part-${boxId}-new-frame-feeder`}
value={choice.draft[field.key] ?? ""}
onChange={(value) => onChange({ ...choice, draft: { ...choice.draft, [field.key]: value } })}
/>
))}
</>
) : null}
</div>
);
}