- 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]>
114 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|