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
@@ -0,0 +1,113 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user