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,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
||||
import type { HiveCatalog, HivePart, HiveState } from "@/lib/hive/types";
|
||||
import { GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
|
||||
import { formatAmount } from "./hive-scene/scene-model";
|
||||
|
||||
// DOM overlay of focus mode (spec 9.4): glass „Zurück“ pill, the part's name
|
||||
// and its state as small glass chips, „Ändern“ for parts with a sheet.
|
||||
// Esc leaves focus mode unless a dialog (sheet) has the keyboard.
|
||||
|
||||
export interface FocusChip {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function findPart(state: HiveState, id: string): HivePart | undefined {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === id) return part;
|
||||
const insert = part.inserts?.find((candidate) => candidate.id === id);
|
||||
if (insert) return insert;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Chips for the focused part: frame count of a box, every state field with its value. */
|
||||
export function focusChips(state: HiveState, catalog: HiveCatalog, id: string): FocusChip[] {
|
||||
const part = findPart(state, id);
|
||||
const type = part ? catalog.partTypes.get(part.partTypeId) : undefined;
|
||||
if (!part || !type) return [];
|
||||
const chips: FocusChip[] = [];
|
||||
if (type.kind === "BOX") {
|
||||
const frames = (part.frames ?? []).filter((frame) => frame.type !== "NONE").length;
|
||||
chips.push({ key: "frames", text: frames === 1 ? "1 Rähmchen" : `${frames} Rähmchen` });
|
||||
}
|
||||
for (const field of type.stateSchema) {
|
||||
const raw = part.state[field.key];
|
||||
if (field.kind === "level") {
|
||||
const value = typeof raw === "number" ? raw : 0;
|
||||
chips.push({ key: field.key, text: `${field.label} ${formatAmount(value, field.unit)} von ${formatAmount(field.max, field.unit)}` });
|
||||
} else {
|
||||
const index = Math.max(0, field.options.indexOf(String(raw)));
|
||||
chips.push({ key: field.key, text: `${field.label}: ${field.optionLabels[index] ?? field.options[index]}` });
|
||||
}
|
||||
}
|
||||
const inserts = (part.inserts ?? []).map((insert) => catalog.partTypes.get(insert.partTypeId)?.kind);
|
||||
if (type.kind === "FLOOR") chips.push({ key: "insert", text: inserts.includes("FLOOR_INSERT") ? "Bausperre drin" : "ohne Bausperre" });
|
||||
return chips;
|
||||
}
|
||||
|
||||
export interface HiveFocusBarProps {
|
||||
/** Part in focus mode; nothing is rendered without one. */
|
||||
focusedId: string | null;
|
||||
name: string;
|
||||
chips: FocusChip[];
|
||||
/** Shows „Ändern“ (the part has a sheet and the user may edit). */
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }: HiveFocusBarProps) {
|
||||
useEffect(() => {
|
||||
if (!focusedId) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
// A sheet handles its own Esc.
|
||||
if (event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']")) return;
|
||||
onBack();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [focusedId, onBack]);
|
||||
|
||||
if (!focusedId) return null;
|
||||
|
||||
// Fades in (150 ms); only at motion `full` it also slides down (motion-full variant, spec 9.8).
|
||||
return (
|
||||
<div
|
||||
key={focusedId}
|
||||
className="pointer-events-none absolute inset-x-3 top-3 flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-3">
|
||||
<button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}>
|
||||
<ArrowLeft aria-hidden className="size-4" />
|
||||
Zurück
|
||||
</button>
|
||||
<p className="truncate text-lg font-semibold text-holo-label-fg drop-shadow-[0_1px_8px_rgb(0_0_0/0.6)]">{name}</p>
|
||||
</div>
|
||||
{chips.length > 0 || canEdit ? (
|
||||
<ul className="flex max-w-full flex-wrap gap-1.5" aria-label={`Zustand von ${name}`}>
|
||||
{chips.map((chip) => (
|
||||
<li key={chip.key} className={`rounded-full px-3 py-1 text-xs font-medium ${GLASS_SURFACE}`}>
|
||||
{chip.text}
|
||||
</li>
|
||||
))}
|
||||
{canEdit ? (
|
||||
<li>
|
||||
<button type="button" onClick={onEdit} className={`pointer-events-auto ${GLASS_PILL}`}>
|
||||
<SlidersHorizontal aria-hidden className="size-4" />
|
||||
Ändern
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user