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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent c608f5d405
commit 6e13236d49
100 files changed
+9432 -209

No files matched your search

@@ -28,7 +28,7 @@ function link(path: string, entries: [string, string][]): string {
/** The feeder field's own name („Flüssigfutter“), else the feed type's. */
function feedName(followUp: Extract<FollowUp, { kind: "feeding" }>, catalog: HiveCatalog): string {
for (const type of catalog.partTypes.values()) {
if (type.kind !== "FEEDER") continue;
if (type.kind !== "FEEDER" && type.kind !== "FRAME_FEEDER") continue;
for (const field of type.stateSchema) {
if (field.kind === "level" && field.feedType === followUp.feedType && field.unit === followUp.unit) {
return field.label;
@@ -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>
);
}
@@ -167,13 +167,29 @@ describe("HiveCard", () => {
expect(toggle).toHaveAttribute("aria-pressed", "true");
});
it("keeps the outline from growing the card: it scrolls in an absolutely placed child", async () => {
it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
const scroll = screen.getByTestId("hive-outline-scroll");
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
expect(scroll).toHaveClass("overflow-y-auto");
// The panel fills the space above the button bar inside an absolutely placed column,
// so the stage (and the row) never grows and the buttons stay reachable.
const column = scroll.parentElement!.parentElement!;
expect(column).toHaveClass("order-1", "min-h-0", "flex-1");
expect(column.parentElement).toHaveClass("absolute", "inset-0");
const buttons = screen.getByRole("button", { name: "Aufbau als Liste" }).closest(".order-2");
expect(buttons?.parentElement).toBe(column.parentElement);
});
it("shows title and facts as a frosted overlay and the actions as glass buttons on the stage", async () => {
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("Beute")).toBeInTheDocument();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
const list = screen.getByRole("button", { name: "Aufbau als Liste" });
expect(list).toHaveTextContent("Liste");
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveClass("backdrop-blur-md");
});
it("points „Aufbau als Liste“ at the outline", async () => {
@@ -1,11 +1,12 @@
"use client";
import { useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { History, ListTree } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Boxes, History, ListTree } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth/auth-context";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
@@ -21,6 +22,8 @@ import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet";
import { HiveConflictSheet } from "./hive-conflict-sheet";
import { HiveFactsLine } from "./hive-facts-line";
import { HiveFocusBar, focusChips } from "./hive-focus-bar";
import { GLASS_BUTTON, GLASS_SURFACE } from "./hive-glass";
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
import { HiveInit } from "./hive-init";
import { HiveOutline } from "./hive-outline";
@@ -30,7 +33,7 @@ import { HiveVisualSlot } from "./hive-visual-slot";
import { PartSheet } from "./part-sheet";
import { useHiveMutation } from "./use-hive-mutation";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
import { HiveDraftError } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit";
@@ -45,25 +48,33 @@ export interface HiveCardProps {
onOpsChange?: (ops: HiveOp[]) => void;
}
/** Colony page card: model, facts line, outline, history (spec 9). Fixed height below lg, as tall as „Stammdaten“ above. */
/**
* Colony page card (spec 9): a full-bleed holo stage with frosted overlays for
* title and facts, focus bar, glass buttons and the outline panel. Fixed
* height below lg; at lg as tall as „Stammdaten“ (everything inside is
* positioned over the stage, so the card never grows the row).
*/
export function HiveCard(props: HiveCardProps) {
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<Card className="h-[min(55svh,520px)] min-h-80 lg:col-span-2 lg:h-auto lg:min-h-0">
<CardHeader>
<CardTitle>Beute</CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col">
<div className="flex h-full min-h-0 flex-col gap-3">
<HiveCardContent {...props} />
</div>
</CardContent>
<Card className="h-[min(55svh,520px)] min-h-80 gap-0 py-0 lg:col-span-2 lg:h-auto lg:min-h-0">
<HiveCardContent {...props} />
</Card>
</HiveViewProvider>
);
}
/** Card surface for the states without a stage (no setup yet, error). */
function PlainCardBody({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
<CardTitle>Beute</CardTitle>
{children}
</div>
);
}
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
const { hasPermission } = useAuth();
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
@@ -72,10 +83,15 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
return <HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />;
return (
<PlainCardBody>
<HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />
</PlainCardBody>
);
}
if (hive.isError || systems.isError) {
return (
<PlainCardBody>
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
<Button
@@ -90,6 +106,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
Erneut versuchen
</Button>
</div>
</PlainCardBody>
);
}
if (!hive.data || !catalog) {
@@ -97,7 +114,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
<div
role="status"
aria-label="Beute wird geladen"
className="min-h-40 flex-1 rounded-lg bg-muted motion-safe:animate-pulse"
className="min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
/>
);
}
@@ -121,6 +138,7 @@ interface FrameTarget {
interface PartTarget {
id: string;
evaporator: boolean;
frameFeeder: boolean;
}
interface PastState {
@@ -193,6 +211,13 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
};
const partName = (id: string) => partDisplayName(shownState, catalog, id);
const focusedId = focusedIdOf(view);
const leaveFocus = () => view.openBox(null);
/** Parts whose state the part sheet edits (a box is changed through its frames). */
const hasSheet = (id: string) => {
const type = findPartKind(shownState, catalog, id);
return type !== undefined && type.kind !== "BOX" && (type.stateSchema.length > 0 || type.kind === "FLOOR");
};
function editFrame(boxId: string, slot: number) {
if (!editable) return;
@@ -200,11 +225,11 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
setFrameOpen(true);
}
function editPart(id: string) {
function editPart(id: string, intent?: "frameFeeder") {
if (!editable) return;
// The outline's „Verdunster einsetzen“ action reports its box.
const evaporator = findPartKind(workingState, catalog, id)?.kind === "BOX";
setPartTarget({ id, evaporator });
// The outline's add actions report their box; „Futtertasche einsetzen“ says so, the other is the evaporator.
const isBox = findPartKind(workingState, catalog, id)?.kind === "BOX";
setPartTarget({ id, evaporator: isBox && intent !== "frameFeeder", frameFeeder: isBox && intent === "frameFeeder" });
setPartOpen(true);
}
@@ -222,90 +247,115 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
return (
<>
{past ? <HiveVersionBar occurredOn={past.occurredOn} onBack={() => {
setPast(null);
historyButtonRef.current?.focus();
}} /> : null}
<div className="flex min-h-0 flex-1 flex-col gap-3 sm:flex-row">
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
<div className="absolute inset-0">
<HiveVisualSlot
layout={layout}
catalog={catalog}
partName={partName}
onSelectPart={selectPart}
onSelectFrame={editFrame}
/>
</div>
{view.openBoxId ? (
<Button
type="button"
variant="secondary"
size="touch"
className="absolute start-2 top-2"
onClick={() => view.openBox(null)}
>
Zurück
</Button>
) : null}
</div>
<div
id={outlineId}
className={
showOutline
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
: "sr-only"
}
>
<div className="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
<HiveOutline
ref={outlineRef}
<div className="relative isolate min-h-0 flex-1 overflow-hidden bg-holo-bg text-holo-label-fg">
<div className="absolute inset-0">
<HiveVisualSlot
layout={layout}
state={shownState}
catalog={catalog}
canEdit={editable}
announcement={announcement}
onEditPart={editPart}
onEditFrame={editFrame}
partName={partName}
onSelectPart={selectPart}
onSelectFrame={editFrame}
/>
</div>
{focusedId ? (
<HiveFocusBar
focusedId={focusedId}
name={partName(focusedId)}
chips={focusChips(shownState, catalog, focusedId)}
canEdit={editable && hasSheet(focusedId)}
onEdit={() => editPart(focusedId)}
onBack={leaveFocus}
/>
) : (
<div
className={`pointer-events-none absolute start-3 top-3 max-w-[min(26rem,calc(100%-1.5rem))] rounded-2xl px-4 py-3 ${GLASS_SURFACE}`}
>
<CardTitle className="text-xl font-semibold tracking-tight">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
</div>
)}
{/* One column for the outline panel and the button bar: the panel always ends above the
buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */}
<div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2">
<div className="order-2 flex flex-col items-center gap-2 p-1">
{past ? (
<div className="pointer-events-auto">
<HiveVersionBar
occurredOn={past.occurredOn}
onBack={() => {
setPast(null);
historyButtonRef.current?.focus();
}}
/>
</div>
) : null}
{focusedId ? (
// In focus mode the top belongs to the focus bar; the facts stay readable here.
// Phones keep the room for the part; the focus chips carry its state there.
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
</div>
) : null}
<div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2">
{showRebuild && !offline ? (
<Link href={`/colonies/${encodeURIComponent(colonyId)}/hive`} className={GLASS_BUTTON}>
<Boxes aria-hidden />
Umbauen
</Link>
) : null}
{showRebuild && offline ? (
<button type="button" disabled aria-describedby="hive-rebuild-hint" className={GLASS_BUTTON}>
<Boxes aria-hidden />
Umbauen
</button>
) : null}
<button
type="button"
aria-pressed={showOutline}
aria-controls={outlineId}
aria-label="Aufbau als Liste"
onClick={() => setShowOutline(!showOutline)}
className={GLASS_BUTTON}
>
<ListTree aria-hidden />
Liste
</button>
<button ref={historyButtonRef} type="button" onClick={() => setHistoryOpen(true)} className={GLASS_BUTTON}>
<History aria-hidden />
Verlauf
</button>
</div>
{showRebuild && offline ? (
<p id="hive-rebuild-hint" className={`rounded-full px-3 py-1 text-xs ${GLASS_SURFACE}`}>
{OFFLINE_REBUILD_HINT}
</p>
) : null}
</div>
<div className="order-1 flex min-h-0 flex-1 justify-end">
<div
id={outlineId}
className={
showOutline
? "pointer-events-auto z-10 flex w-[min(18rem,100%)] flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/95 text-card-foreground shadow-xl backdrop-blur-md duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-right-4"
: "sr-only"
}
>
<div className="min-h-0 flex-1 overflow-y-auto p-2" data-testid="hive-outline-scroll">
<HiveOutline
ref={outlineRef}
state={shownState}
catalog={catalog}
canEdit={editable}
announcement={announcement}
onEditPart={editPart}
onEditFrame={editFrame}
/>
</div>
</div>
</div>
</div>
</div>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} />
<div className="flex flex-wrap items-center gap-2">
{showRebuild && !offline ? (
<Link
href={`/colonies/${encodeURIComponent(colonyId)}/hive`}
className={buttonVariants({ size: "touch" })}
>
Umbauen
</Link>
) : null}
{showRebuild && offline ? (
<Button size="touch" disabled aria-describedby="hive-rebuild-hint">
Umbauen
</Button>
) : null}
<Button
type="button"
variant="outline"
size="touch"
aria-pressed={showOutline}
aria-controls={outlineId}
onClick={() => setShowOutline(!showOutline)}
>
<ListTree aria-hidden />
Aufbau als Liste
</Button>
<Button ref={historyButtonRef} type="button" variant="outline" size="touch" onClick={() => setHistoryOpen(true)}>
<History aria-hidden />
Verlauf
</Button>
</div>
{showRebuild && offline ? (
<p id="hive-rebuild-hint" className="text-xs text-muted-foreground">
{OFFLINE_REBUILD_HINT}
</p>
) : null}
<HiveSheetShell open={historyOpen} onOpenChange={setHistoryOpen} title="Verlauf">
<HiveHistory
colonyId={colonyId}
@@ -337,6 +387,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
catalog={catalog}
partId={partTarget.id}
startWithEvaporator={partTarget.evaporator}
startWithFrameFeeder={partTarget.frameFeeder}
submit={submit}
outlineRef={outlineRef}
/>
@@ -5,15 +5,17 @@ export interface HiveFactsLineProps {
facts: HiveFacts;
/** The latest change; omitted while a past state is shown. */
lastChange?: { occurredOn: string; source: HiveChangeSource } | null;
/** "stage": light text for the frosted overlay on the holo stage. */
tone?: "card" | "stage";
}
/** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */
export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) {
export function HiveFactsLine({ facts, lastChange, tone = "card" }: HiveFactsLineProps) {
return (
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{hiveFactsText(facts)}</p>
{lastChange ? (
<p className="text-xs text-muted-foreground">
<p className={tone === "stage" ? "text-xs text-holo-label-fg/80" : "text-xs text-muted-foreground"}>
Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}
</p>
) : null}
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveFocusBar, focusChips } from "./hive-focus-bar";
const catalog = inlineCatalog();
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame)) }),
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
function renderBar(extra: Partial<Parameters<typeof HiveFocusBar>[0]> = {}) {
const props = {
focusedId: "fe",
name: "Futterzarge",
chips: focusChips(state, catalog, "fe"),
canEdit: true,
onEdit: vi.fn(),
onBack: vi.fn(),
...extra,
};
render(<HiveFocusBar {...props} />);
return props;
}
describe("focusChips", () => {
it("lists levels with their maximum and choices with their German text", () => {
expect(focusChips(state, catalog, "fe").map((chip) => chip.text)).toEqual([
"Flüssigfutter 2,5 l von 3 l",
"Futterteig 1 kg von 2,5 kg",
]);
expect(focusChips(state, catalog, "lid").map((chip) => chip.text)).toEqual(["Futterloch: zu"]);
});
it("counts the frames of a box and says whether the floor holds a Bausperre", () => {
expect(focusChips(state, catalog, "b1")[0].text).toBe("8 Rähmchen");
expect(focusChips(state, catalog, "f").at(-1)!.text).toBe("Bausperre drin");
});
it("finds inserts and returns nothing for unknown ids", () => {
expect(focusChips(state, catalog, "fi")).toEqual([]);
expect(focusChips(state, catalog, "nope")).toEqual([]);
});
});
describe("HiveFocusBar", () => {
it("renders nothing outside focus mode", () => {
const { container } = render(
<HiveFocusBar focusedId={null} name="" chips={[]} canEdit={false} onEdit={vi.fn()} onBack={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("shows „Zurück“, the name, the state chips and „Ändern“", () => {
const props = renderBar();
expect(screen.getByText("Futterzarge")).toBeInTheDocument();
expect(screen.getByText("Flüssigfutter 2,5 l von 3 l")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
expect(props.onBack).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Ändern" }));
expect(props.onEdit).toHaveBeenCalled();
});
it("hides „Ändern“ for readers", () => {
renderBar({ canEdit: false });
expect(screen.queryByRole("button", { name: "Ändern" })).toBeNull();
});
it("leaves focus mode on Esc", () => {
const props = renderBar();
fireEvent.keyDown(window, { key: "Escape" });
expect(props.onBack).toHaveBeenCalledTimes(1);
});
it("leaves Esc to an open sheet", () => {
const props = renderBar();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
const input = document.createElement("input");
dialog.appendChild(input);
document.body.appendChild(dialog);
fireEvent.keyDown(input, { key: "Escape" });
expect(props.onBack).not.toHaveBeenCalled();
dialog.remove();
});
});
@@ -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>
);
}
@@ -0,0 +1,11 @@
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
// above 4.5:1; focus rings use the strong edge colour.
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${GLASS_SURFACE}`;
@@ -319,4 +319,87 @@ describe("HiveOutline", () => {
expect(document.body).toHaveFocus();
});
});
describe("frame feeder", () => {
const pocketState: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b1", "box", {
frames: Array.from({ length: 6 }, (_, index) => ({
slot: index + 1,
type: index === 2 || index === 3 ? ("NONE" as const) : ("BROOD" as const),
})),
inserts: [part("ff", "frame-feeder", { slot: 3, state: { syrupLiters: 1.5 } })],
}),
part("r", "roof"),
],
};
function open(props: Partial<React.ComponentProps<typeof HiveOutline>> = {}) {
const result = setup({ state: pocketState, ...props });
act(() => result.store.openBox("b1"));
return result;
}
it("lists the pocket at its slot position and hides the covered frames", () => {
open();
const items = names();
const start = items.findIndex((t) => t?.startsWith("R1"));
expect(items.slice(start, start + 5)).toEqual([
"R1 · Brutwabe",
"R2 · Brutwabe",
"F3–4 · Futtertasche · 1,5 l",
"R5 · Brutwabe",
"R6 · Brutwabe",
]);
expect(items.some((t) => t?.startsWith("R3") || t?.startsWith("R4"))).toBe(false);
});
it("nests the pocket one level below its box and edits it on Enter", async () => {
const { user, onEditPart } = open();
const pocket = screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ });
expect(pocket).toHaveAttribute("aria-level", "2");
await user.click(pocket);
expect(onEditPart).toHaveBeenCalledWith("ff");
});
it("offers Futtertasche einsetzen while a position is free and passes the intent", async () => {
const { user, onEditPart } = open();
await user.click(screen.getByRole("treeitem", { name: /Futtertasche einsetzen/ }));
expect(onEditPart).toHaveBeenCalledWith("b1", "frameFeeder");
});
it("hides the add action without canEdit or when no position is left", () => {
const { unmount } = open({ canEdit: false });
expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull();
unmount();
const full: HiveState = {
...pocketState,
stack: pocketState.stack.map((p) =>
p.id === "b1"
? {
...p,
frames: Array.from({ length: 10 }, (_, index) => ({ slot: index + 1, type: "NONE" as const })),
inserts: [1, 3, 5, 7, 9].map((slot) => part(`ff${slot}`, "frame-feeder", { slot })),
}
: p,
),
};
open({ state: full });
expect(screen.queryByRole("treeitem", { name: /Futtertasche einsetzen/ })).toBeNull();
});
it("focusPart on the pocket expands the box and focuses the pocket", () => {
const ref = createRef<HiveOutlineHandle>();
const store = createHiveViewStore();
render(
<HiveViewProvider store={store}>
<HiveOutline ref={ref} state={pocketState} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
</HiveViewProvider>,
);
act(() => ref.current?.focusPart("ff"));
expect(screen.getByRole("treeitem", { name: /^F3–4 · Futtertasche/ })).toHaveFocus();
});
});
});
@@ -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") {
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
describe("cameraBasis", () => {
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
const { back, right, up } = cameraBasis();
expect(Math.asin(back[1]) * (180 / Math.PI)).toBeCloseTo(20, 6);
expect(Math.atan2(back[0], back[2]) * (180 / Math.PI)).toBeCloseTo(30, 6);
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
expect(dot(back, right)).toBeCloseTo(0, 9);
expect(dot(back, up)).toBeCloseTo(0, 9);
expect(dot(right, up)).toBeCloseTo(0, 9);
expect(up[1]).toBeGreaterThan(0);
});
});
describe("fitDistance", () => {
const hive: [number, number, number] = [5.25, 7, 4.3];
it("moves the camera further away for a taller model", () => {
expect(fitDistance([5.25, 10, 4.3], 1.6)).toBeGreaterThan(fitDistance(hive, 1.6));
});
it("moves further away in a narrow card than in a wide one", () => {
expect(fitDistance(hive, 0.6)).toBeGreaterThan(fitDistance(hive, 2));
});
it("grows with the margin and survives a zero aspect", () => {
expect(fitDistance(hive, 1.5, 1.4)).toBeGreaterThan(fitDistance(hive, 1.5, 1.2));
expect(Number.isFinite(fitDistance(hive, 0))).toBe(true);
});
it("keeps a minimum distance for a flat part", () => {
expect(fitDistance([0, 0, 0], 1)).toBeGreaterThanOrEqual(1);
});
});
describe("overlayMargin", () => {
it("leaves more room on a short card than on a tall one, capped at 2", () => {
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9);
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9);
expect(overlayMargin(200, 150)).toBe(2);
expect(overlayMargin(0)).toBe(1);
});
});
describe("focusFrame", () => {
it("pulls the view toward the ghost hive and keeps the focused part inside the box", () => {
const frame = focusFrame([-2, 1, 5], [6, 3, 5], [0, 4, 0], [0.5, 0.5, 0.5]);
expect(frame.center).toEqual([-1, 2.5, 2.5]);
expect(frame.size).toEqual([8, 6, 10]);
});
it("is the focused part itself without pull", () => {
expect(focusFrame([1, 2, 3], [4, 5, 6], [0, 0, 0], [0, 0, 0])).toEqual({ center: [1, 2, 3], size: [4, 5, 6] });
});
});
describe("cameraPosition", () => {
it("places the camera along the viewing direction from the centre", () => {
const [x, y, z] = cameraPosition([1, 2, 3], 10);
const { back } = cameraBasis();
expect(x).toBeCloseTo(1 + back[0] * 10, 9);
expect(y).toBeCloseTo(2 + back[1] * 10, 9);
expect(z).toBeCloseTo(3 + back[2] * 10, 9);
});
});
@@ -0,0 +1,114 @@
import type { Vec3 } from "@/lib/hive/layout";
// Camera placement for the card (spec 9.2): fixed viewing direction slightly
// from above and in front, distance chosen so the target box fits the card.
export const CAMERA_ELEVATION_DEG = 20;
export const CAMERA_AZIMUTH_DEG = 30;
export const CAMERA_FOV_DEG = 30;
/** Space around the model, as a factor on its projected size. */
export const FIT_MARGIN = 1.05;
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
export const FOCUS_FIT_MARGIN = 0.95;
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */
export const OVERLAY_RESERVE_PX = 150;
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
const toRad = (deg: number): number => (deg * Math.PI) / 180;
export interface CameraBasis {
/** Unit vector from the target to the camera. */
back: Vec3;
right: Vec3;
up: Vec3;
}
export function cameraBasis(
elevationDeg: number = CAMERA_ELEVATION_DEG,
azimuthDeg: number = CAMERA_AZIMUTH_DEG,
): CameraBasis {
const elevation = toRad(elevationDeg);
const azimuth = toRad(azimuthDeg);
const back: Vec3 = [
Math.sin(azimuth) * Math.cos(elevation),
Math.sin(elevation),
Math.cos(azimuth) * Math.cos(elevation),
];
const right: Vec3 = [Math.cos(azimuth), 0, -Math.sin(azimuth)];
// up = back x right
const up: Vec3 = [
back[1] * right[2] - back[2] * right[1],
back[2] * right[0] - back[0] * right[2],
back[0] * right[1] - back[1] * right[0],
];
return { back, right, up };
}
/**
* Extra margin so the model fits the part of the card the overlays leave free:
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2.
*/
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number {
if (!(heightPx > 0)) return 1;
return heightPx / Math.max(heightPx - reservePx, heightPx / 2);
}
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */
function halfExtent(size: Vec3, axis: Vec3): number {
return (Math.abs(axis[0]) * size[0] + Math.abs(axis[1]) * size[1] + Math.abs(axis[2]) * size[2]) / 2;
}
/** Distance from the box centre at which the box fills the view with `margin` around it. */
export function fitDistance(
size: Vec3,
aspect: number,
margin: number = FIT_MARGIN,
fovDeg: number = CAMERA_FOV_DEG,
basis: CameraBasis = cameraBasis(),
): number {
const tanHalf = Math.tan(toRad(fovDeg) / 2);
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const halfWidth = halfExtent(size, basis.right);
const halfHeight = halfExtent(size, basis.up);
const halfDepth = halfExtent(size, basis.back);
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin;
// The near face of the box sits half its depth closer to the camera.
return Math.max(distance + halfDepth, 1);
}
/**
* Box the camera fits in focus mode: the focused part plus part of the way
* toward the ghost hive, so the ghost peeks in behind it (mockup h2) and a
* tall card is not left empty above a flat part.
*/
export function focusFrame(
focusCenter: Vec3,
focusSize: Vec3,
hiveCenter: Vec3,
pull: Vec3 = FOCUS_PULL,
): { center: Vec3; size: Vec3 } {
const shift: Vec3 = [
(hiveCenter[0] - focusCenter[0]) * pull[0],
(hiveCenter[1] - focusCenter[1]) * pull[1],
(hiveCenter[2] - focusCenter[2]) * pull[2],
];
return {
center: [focusCenter[0] + shift[0], focusCenter[1] + shift[1], focusCenter[2] + shift[2]],
// The focused part stays fully inside the box around the shifted centre.
size: [
focusSize[0] + 2 * Math.abs(shift[0]),
focusSize[1] + 2 * Math.abs(shift[1]),
focusSize[2] + 2 * Math.abs(shift[2]),
],
};
}
/** Camera position looking at `center` along the fixed viewing direction. */
export function cameraPosition(center: Vec3, distance: number, basis: CameraBasis = cameraBasis()): Vec3 {
return [
center[0] + basis.back[0] * distance,
center[1] + basis.back[1] * distance,
center[2] + basis.back[2] * distance,
];
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit";
import { useSceneKit } from "./scene-kit";
import { placeCamera } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { restBounds, stepToward } from "./use-hive-motion";
import type { FocusInfo } from "./use-hive-motion";
// Auto-fit (spec 9.2): the camera looks from 20° above and 30° to the side
// and moves so the hive (or the focused part) fills the card. It refits on
// layout, card size and focus changes; damped at `full`, at once otherwise.
export interface CameraRigProps {
layout: HiveLayout;
focus: FocusInfo | null;
/** Receives the current look-at point every frame; the model rig turns around it. */
onPivot: (center: Vec3) => void;
}
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const { motion } = useSceneKit();
const camera = useThree((state) => state.camera);
const width = useThree((state) => state.size.width);
const height = useThree((state) => state.size.height);
const invalidate = useThree((state) => state.invalidate);
const goal = useMemo(() => {
const aspect = height > 0 ? width / height : 1;
const room = overlayMargin(height);
const bounds = restBounds(layout);
if (focus) {
const frame = focusFrame(focus.center, focus.size, bounds.center);
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) };
}
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) };
}, [focus, layout, width, height]);
const current = useRef<{ center: Vec3; distance: number } | null>(null);
useEffect(() => {
invalidate();
}, [goal, invalidate]);
useFrame((_, delta) => {
const damped = motion === "full";
const dt = Math.min(delta, MAX_FRAME_SECONDS);
if (!current.current) {
current.current = { center: [...goal.center], distance: goal.distance };
} else {
const state = current.current;
state.center = [
stepToward(state.center[0], goal.center[0], dt, damped),
stepToward(state.center[1], goal.center[1], dt, damped),
stepToward(state.center[2], goal.center[2], dt, damped),
];
state.distance = stepToward(state.distance, goal.distance, dt, damped);
}
const { center, distance } = current.current;
onPivot(center);
placeCamera(camera, center, distance);
const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
if (!settled) invalidate();
});
return null;
}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three";
import type { Mesh } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit";
import { placeFill, setUniform } from "./scene-mutations";
// Levels (spec 9.3): a glowing, half-transparent volume whose surface moves
// gently at motion `full`. Height = value / max of the space it fills.
/** Surface wave height in layout units (2 mm). */
const WOBBLE = 0.02;
export type FillKind = "honey" | "acid" | "fondant";
export interface FillVolumeProps {
/** Centre and size of the full space (local to the part). */
center: Vec3;
size: Vec3;
/** 0…1 */
ratio: number;
kind: FillKind;
shape?: "box" | "cylinder";
}
export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVolumeProps) {
const { theme, motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const mesh = useRef<Mesh>(null);
const shown = useRef(ratio);
const geometry = useMemo(
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)),
[shape],
);
const material = useMemo(() => {
if (kind === "fondant") {
return new MeshStandardMaterial({
color: theme.fondant,
roughness: 0.55,
emissive: theme.fondant,
emissiveIntensity: 0.18,
transparent: true,
opacity: 0.94,
});
}
return kind === "acid"
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
}, [kind, theme]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useEffect(() => {
invalidate();
}, [ratio, invalidate]);
useFrame((state, delta) => {
const node = mesh.current;
if (!node) return;
const dt = Math.min(delta, MAX_FRAME_SECONDS);
shown.current = stepToward(shown.current, ratio, dt, motion === "full");
const height = placeFill(node, center, size, shown.current, shape === "cylinder");
if ("uniforms" in material) {
setUniform(material, "uTime", state.clock.elapsedTime);
// The wave lives in unit space; divide by the height so it stays 2 mm.
setUniform(material, "uWobble", motion === "full" && kind !== "fondant" ? WOBBLE / height : 0);
}
if (shown.current !== ratio) invalidate();
});
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />;
}
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels";
import { createEdgeMaterial, createGlassMaterial } from "./materials";
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform } from "./scene-mutations";
import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import type { FrameTarget } from "./use-hive-motion";
// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the
// box. In an open box each frame is a clear glass plate; hovered or selected
// it rises and takes its type look (colour and comb pattern).
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
export function FrameHints({
frames,
hostCenter,
glass,
edges,
}: {
frames: FrameBox[];
hostCenter: Vec3;
glass: ShaderMaterial;
edges: ShaderMaterial;
}) {
const geometries = useMemo(() => {
const hints = frames
.filter((frame) => frame.type !== "NONE")
.map((frame) => frameHintSpec(frame.size, sub(frame.center, hostCenter)));
return {
glass: buildGlassGeometry(hints.map((hint) => hint.slab)),
edges: buildEdgeGeometry(hints.flatMap((hint) => hint.edges)),
};
}, [frames, hostCenter]);
useEffect(
() => () => {
geometries.glass.dispose();
geometries.edges.dispose();
},
[geometries],
);
return (
<>
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
</>
);
}
export interface FramePlateProps {
frame: FrameBox;
hostCenter: Vec3;
target: FrameTarget;
handlers: NodeHandlers;
label?: SceneLabel;
}
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion } = useSceneKit();
const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
const bars = geometries.shape(`frame-bars|${sizeKey}`, (): PartShapeSpec => ({
slabs: spec.bars,
edges: spec.edges,
grids: [],
frost: 0,
}));
const comb = spec.comb
? geometries.shape(`frame-comb|${sizeKey}|${spec.comb.size[0].toFixed(3)}`, (): PartShapeSpec => ({
slabs: spec.comb ? [spec.comb] : [],
edges: [],
grids: [],
frost: 0,
}))
: null;
const texture = textures.get(frame.type);
const materials = useMemo(
() => ({
bars: createGlassMaterial(theme, { frost: 0.28 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
edges: createEdgeMaterial(theme),
typed: texture
? new MeshStandardMaterial({
map: texture,
emissiveMap: texture,
emissive: "#ffffff",
emissiveIntensity: 0.95,
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
metalness: 0.05,
transparent: true,
opacity: 0,
})
: null,
}),
[theme, texture, frame.type],
);
useEffect(
() => () => {
materials.bars.dispose();
materials.comb.dispose();
materials.edges.dispose();
materials.typed?.dispose();
},
[materials],
);
const typeLook = target.look === "type";
const apply = ({ opacity, glow, look }: AnimatedValues) => {
setUniform(materials.bars, "uOpacity", opacity);
setUniform(materials.bars, "uGlow", glow);
// The glass comb gives way to the textured comb as the look fades in.
setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0)));
setUniform(materials.comb, "uGlow", glow);
setUniform(materials.edges, "uOpacity", opacity);
setUniform(materials.edges, "uIntensity", 1.3 * glow);
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
};
useAnimatedNode(
group,
{ position: target.position, scale: 1, opacity: target.opacity, glow: typeLook ? 1.7 : 1, look: typeLook ? 1 : 0 },
motion,
apply,
);
const restY = frame.center[1] - hostCenter[1];
const rise = Math.max(0, target.position[1] - restY);
const [fx, fy, fz] = frame.size;
return (
<group ref={group}>
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
{comb ? (
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
) : null}
{comb && materials.typed ? (
<mesh geometry={comb.glass} material={materials.typed} raycast={noRaycast} renderOrder={2} dispose={null} />
) : null}
<mesh geometry={bars.edges} material={materials.edges} raycast={noRaycast} renderOrder={4} dispose={null} />
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
<mesh
position={[0, -rise / 2, 0]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
onPointerDown={handlers.onPointerDown}
onClick={handlers.onClick}
userData={{ hit: `${frame.boxId}#${frame.slot}` }}
>
<boxGeometry args={[fx, fy + rise, fz]} />
<meshBasicMaterial visible={false} />
</mesh>
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
</group>
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { FRAME_TYPES } from "@/lib/hive/types";
import type { FrameType } from "@/lib/hive/types";
import { createFrameTextureCache, framePatternSpec } from "./frame-textures";
/** A canvas whose 2D context records every fill colour and shape drawn. */
function fakeCanvas() {
const fills: string[] = [];
let fillStyle: unknown = "";
const gradient = { addColorStop: vi.fn() };
const context = {
get fillStyle() {
return fillStyle;
},
set fillStyle(value: unknown) {
fillStyle = value;
},
strokeStyle: "",
lineWidth: 1,
globalAlpha: 1,
fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
ellipse: vi.fn(),
fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")),
stroke: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
createLinearGradient: vi.fn(() => gradient),
};
const canvas = { width: 0, height: 0, getContext: vi.fn(() => context) };
return { canvas: canvas as unknown as HTMLCanvasElement, fills };
}
describe("framePatternSpec", () => {
it("has no pattern for the open frame, the divider and an empty slot", () => {
expect(framePatternSpec("DRONE_FRAME")).toBeNull();
expect(framePatternSpec("DIVIDER")).toBeNull();
expect(framePatternSpec("NONE")).toBeNull();
});
it("gives every comb type its own pattern", () => {
const combs = FRAME_TYPES.filter((type) => framePatternSpec(type) !== null);
expect(combs).toEqual(["BROOD", "DRONE", "HONEY", "POLLEN", "FEED", "FOUNDATION", "EMPTY_COMB"]);
const serialised = new Set(combs.map((type) => JSON.stringify(framePatternSpec(type))));
expect(serialised.size).toBe(combs.length);
});
it("draws drone cells larger and domed, honey glossy, pollen speckled, foundation without fills", () => {
expect(framePatternSpec("DRONE")!.cellRadius).toBeGreaterThan(framePatternSpec("BROOD")!.cellRadius);
expect(framePatternSpec("DRONE")!.dome).toBe(true);
expect(framePatternSpec("HONEY")!.gloss).toBeGreaterThan(framePatternSpec("BROOD")!.gloss);
expect(framePatternSpec("POLLEN")!.speckle).toBe(true);
expect(framePatternSpec("FOUNDATION")!.fills).toHaveLength(0);
expect(framePatternSpec("EMPTY_COMB")!.open).toBe(1);
});
});
describe("createFrameTextureCache", () => {
it("returns one cached texture per type and none for types without a comb", () => {
const made: ReturnType<typeof fakeCanvas>[] = [];
const cache = createFrameTextureCache(() => {
const fake = fakeCanvas();
made.push(fake);
return fake.canvas;
});
const brood = cache.get("BROOD");
expect(brood).not.toBeNull();
expect(cache.get("BROOD")).toBe(brood);
expect(cache.get("NONE")).toBeNull();
expect(cache.get("DIVIDER")).toBeNull();
expect(made).toHaveLength(1);
});
it("draws different pixels for different types", () => {
const drawn = new Map<FrameType, string[]>();
let current: FrameType = "BROOD";
const cache = createFrameTextureCache(() => {
const fake = fakeCanvas();
drawn.set(current, fake.fills);
return fake.canvas;
});
for (const type of ["BROOD", "HONEY", "POLLEN", "FOUNDATION"] as FrameType[]) {
current = type;
cache.get(type);
}
const signatures = new Set([...drawn.values()].map((fills) => fills.join("|")));
expect(signatures.size).toBe(4);
expect(drawn.get("BROOD")!.length).toBeGreaterThan(50);
});
it("disposes every texture it made", () => {
const cache = createFrameTextureCache(() => fakeCanvas().canvas);
const texture = cache.get("HONEY")!;
const dispose = vi.spyOn(texture, "dispose");
cache.dispose();
expect(dispose).toHaveBeenCalled();
expect(cache.get("HONEY")).not.toBe(texture);
});
it("returns null when the canvas has no 2D context", () => {
const cache = createFrameTextureCache(
() => ({ width: 0, height: 0, getContext: () => null }) as unknown as HTMLCanvasElement,
);
expect(cache.get("BROOD")).toBeNull();
});
});
@@ -0,0 +1,276 @@
import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three";
import type { FrameType } from "@/lib/hive/types";
// Procedural comb textures for the frame look (spec 9.3): colour AND pattern
// per frame type, drawn once on a canvas and cached per type. No image assets.
const WIDTH = 512;
const HEIGHT = 288;
interface WeightedColor {
color: string;
weight: number;
}
export interface FramePatternSpec {
/** Wax / comb background. */
base: string;
/** Cell wall colour. */
rim: string;
/** Hexagon radius in canvas pixels. */
cellRadius: number;
/** Colours of filled cells, picked by weight. */
fills: WeightedColor[];
/** Share of cells drawn as open (empty, dark centre). */
open: number;
/** Capped cells bulge (drone brood). */
dome: boolean;
/** Strength of the glossy highlight on capped cells (0…1). */
gloss: number;
/** Small coloured grains inside cells (pollen). */
speckle: boolean;
/** Seed of the pseudo-random pattern, so every type looks the same each time. */
seed: number;
}
const SPECS: Partial<Record<FrameType, FramePatternSpec>> = {
BROOD: {
base: "#b8741f",
rim: "#ffd27a",
cellRadius: 13,
fills: [
{ color: "#eaa443", weight: 6 },
{ color: "#dc952f", weight: 3 },
{ color: "#f4b65a", weight: 2 },
],
open: 0.03,
dome: false,
gloss: 0.18,
speckle: false,
seed: 11,
},
DRONE: {
base: "#8a4e14",
rim: "#e8a24c",
cellRadius: 17,
fills: [
{ color: "#c8822e", weight: 5 },
{ color: "#b67224", weight: 3 },
],
open: 0.06,
dome: true,
gloss: 0.3,
speckle: false,
seed: 23,
},
HONEY: {
base: "#b9800f",
rim: "#ffe08a",
cellRadius: 13,
fills: [
{ color: "#f6c64a", weight: 6 },
{ color: "#ffd766", weight: 3 },
{ color: "#e9b032", weight: 2 },
],
open: 0.04,
dome: false,
gloss: 0.75,
speckle: false,
seed: 37,
},
POLLEN: {
base: "#8a4612",
rim: "#f0a050",
cellRadius: 13,
fills: [
{ color: "#f08a24", weight: 4 },
{ color: "#f4c430", weight: 3 },
{ color: "#d0461c", weight: 2 },
{ color: "#9a6a2a", weight: 1 },
],
open: 0.2,
dome: false,
gloss: 0.08,
speckle: true,
seed: 41,
},
FEED: {
base: "#b89a52",
rim: "#fff2c2",
cellRadius: 13,
fills: [
{ color: "#f3dc94", weight: 5 },
{ color: "#ead08a", weight: 3 },
],
open: 0.08,
dome: false,
gloss: 0.45,
speckle: false,
seed: 53,
},
FOUNDATION: {
base: "#e9d8a8",
rim: "#fff8de",
cellRadius: 13,
fills: [],
open: 0,
dome: false,
gloss: 0,
speckle: false,
seed: 61,
},
EMPTY_COMB: {
base: "#4a3418",
rim: "#a8834c",
cellRadius: 13,
fills: [],
open: 1,
dome: false,
gloss: 0,
speckle: false,
seed: 71,
},
};
/** Pattern of a frame type, or null for types without a comb (open frame, divider, empty slot). */
export function framePatternSpec(type: FrameType): FramePatternSpec | null {
return SPECS[type] ?? null;
}
/** Small deterministic PRNG (mulberry32). */
function random(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pick(fills: WeightedColor[], value: number): string {
const total = fills.reduce((sum, entry) => sum + entry.weight, 0);
let left = value * total;
for (const entry of fills) {
left -= entry.weight;
if (left <= 0) return entry.color;
}
return fills[fills.length - 1].color;
}
function hexPath(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
context.beginPath();
for (let corner = 0; corner < 6; corner += 1) {
const angle = (Math.PI / 3) * corner + Math.PI / 6;
const px = x + radius * Math.cos(angle);
const py = y + radius * Math.sin(angle);
if (corner === 0) context.moveTo(px, py);
else context.lineTo(px, py);
}
context.closePath();
}
function drawCell(
context: CanvasRenderingContext2D,
spec: FramePatternSpec,
x: number,
y: number,
next: () => number,
): void {
const inner = spec.cellRadius * 0.82;
const isOpen = spec.fills.length === 0 ? spec.open > 0 : next() < spec.open;
if (isOpen) {
const hollow = context.createRadialGradient(x, y, 0, x, y, inner);
hollow.addColorStop(0, "rgba(74, 38, 8, 0.95)");
hollow.addColorStop(1, "rgba(150, 92, 30, 0.6)");
context.fillStyle = hollow;
hexPath(context, x, y, inner);
context.fill();
return;
}
if (spec.fills.length === 0) return;
const color = pick(spec.fills, next());
if (spec.dome) {
const dome = context.createRadialGradient(x - inner * 0.3, y - inner * 0.3, inner * 0.1, x, y, inner);
dome.addColorStop(0, "#f0b868");
dome.addColorStop(1, color);
context.fillStyle = dome;
} else {
context.fillStyle = color;
}
hexPath(context, x, y, inner);
context.fill();
if (spec.speckle) {
context.fillStyle = pick(spec.fills, next());
context.beginPath();
context.arc(x + (next() - 0.5) * inner, y + (next() - 0.5) * inner, 1 + next() * 1.6, 0, Math.PI * 2);
context.fill();
}
if (spec.gloss > 0) {
context.globalAlpha = spec.gloss;
context.fillStyle = "#fff6dc";
context.beginPath();
context.ellipse(x - inner * 0.28, y - inner * 0.32, inner * 0.34, inner * 0.18, -0.5, 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
}
}
function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec): void {
const next = random(spec.seed);
context.fillStyle = spec.base;
context.fillRect(0, 0, WIDTH, HEIGHT);
const radius = spec.cellRadius;
const stepX = Math.sqrt(3) * radius;
const stepY = 1.5 * radius;
context.strokeStyle = spec.rim;
context.lineWidth = Math.max(1.2, radius * 0.16);
for (let row = 0, y = 0; y < HEIGHT + radius; row += 1, y += stepY) {
const offset = row % 2 === 0 ? 0 : stepX / 2;
for (let x = offset; x < WIDTH + radius; x += stepX) {
drawCell(context, spec, x, y, next);
hexPath(context, x, y, radius);
context.stroke();
}
}
}
export interface FrameTextureCache {
/** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */
get(type: FrameType): CanvasTexture | null;
dispose(): void;
}
const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas");
export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache {
const textures = new Map<FrameType, CanvasTexture | null>();
return {
get(type) {
if (textures.has(type)) return textures.get(type) ?? null;
const spec = framePatternSpec(type);
if (!spec) return null;
const canvas = makeCanvas();
canvas.width = WIDTH;
canvas.height = HEIGHT;
const context = canvas.getContext("2d");
if (!context) {
textures.set(type, null);
return null;
}
drawPattern(context, spec);
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.anisotropy = 4;
textures.set(type, texture);
return texture;
},
dispose() {
textures.forEach((texture) => texture?.dispose());
textures.clear();
},
};
}
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { Canvas } from "@react-three/fiber";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { useHiveView } from "../use-hive-view";
import { CAMERA_FOV_DEG } from "./camera-fit";
import { HiveSceneContent } from "./hive-scene-content";
import { HiveStage, detectStageTier } from "./hive-stage";
import { SceneKitProvider } from "./scene-kit";
import { shouldIdle } from "./use-hive-motion";
export interface HiveCanvasProps {
layout: HiveLayout;
state: HiveState;
catalog: HiveCatalog;
partName: (id: string) => string;
onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void;
/** The first frame is on screen: the SVG may cross-fade out. */
onReady: () => void;
/** The WebGL context was lost; the browser may or may not restore it. */
onContextLost: () => void;
}
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
function useOnScreen(target: RefObject<HTMLElement | null>): { visible: boolean; tabVisible: boolean } {
const [visible, setVisible] = useState(true);
const [tabVisible, setTabVisible] = useState(() => typeof document === "undefined" || !document.hidden);
useEffect(() => {
const element = target.current;
if (!element || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
setVisible(entries.some((entry) => entry.isIntersecting));
});
observer.observe(element);
return () => observer.disconnect();
}, [target]);
useEffect(() => {
const onChange = () => setTabVisible(!document.hidden);
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
}, []);
return { visible, tabVisible };
}
/**
* The 3D hive (spec 9.3/9.4): holo stage plus the interactive model. Loaded
* only through next/dynamic; the canvas is aria-hidden, the outline is the
* accessible path. Renders on demand: frames only while something moves.
*/
export default function HiveCanvas({
layout,
state,
catalog,
partName,
onSelectPart,
onSelectFrame,
onReady,
onContextLost,
}: HiveCanvasProps) {
const motion = useEffectiveMotionLevel();
const view = useHiveView();
const root = useRef<HTMLDivElement>(null);
const { visible, tabVisible } = useOnScreen(root);
const [tier] = useState(detectStageTier);
const idle = shouldIdle({ motion, visible, tabVisible });
return (
<div ref={root} className="h-full w-full">
<Canvas
frameloop="demand"
dpr={[1, 2]}
aria-hidden
// Vertical swipes scroll the page; horizontal drags turn the model.
style={{ touchAction: "pan-y" }}
camera={{ fov: CAMERA_FOV_DEG, near: 0.1, far: 200, position: [8, 6, 14] }}
gl={{ antialias: false, stencil: false, powerPreference: "high-performance" }}
onPointerMissed={() => {
// A click or tap into empty space leaves focus mode and clears the hover.
view.openBox(null);
view.setHovered(null);
view.setHoveredFrame(null);
}}
onCreated={({ gl, invalidate }) => {
gl.domElement.addEventListener("webglcontextlost", (event) => {
// Without preventDefault the browser never restores the context.
event.preventDefault();
onContextLost();
});
invalidate();
onReady();
}}
>
<SceneKitProvider motion={motion}>
<HiveStage tier={tier} />
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={partName}
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={idle}
/>
</SceneKitProvider>
</Canvas>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { Html } from "@react-three/drei";
import type { Vec3 } from "@/lib/hive/layout";
import type { SceneLabel } from "./scene-model";
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
// pill with a thin leader line. At most three at once (sceneLabels caps it).
const PILL =
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md";
export interface HoloLabelProps {
position: Vec3;
text: string;
tone: SceneLabel["tone"];
}
/** A label anchored at `position` (local to its parent): beside a part, above a frame. */
export function HoloLabel({ position, text, tone }: HoloLabelProps) {
return (
<Html position={position} zIndexRange={[30, 10]} style={{ pointerEvents: "none" }}>
{tone === "frame" ? (
<div className="pointer-events-none flex -translate-x-1/2 -translate-y-full flex-col items-center select-none">
<span className={PILL}>{text}</span>
<span aria-hidden className="h-6 w-px bg-holo-edge/80" />
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
</div>
) : (
<div className="pointer-events-none flex -translate-y-1/2 items-center select-none">
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
<span aria-hidden className="h-px w-7 bg-holo-edge/80" />
<span className={PILL}>
{tone === "level" ? <span aria-hidden className="me-2 inline-block size-2 rounded-full bg-amber-400 align-middle" /> : null}
{text}
</span>
</div>
)}
</Html>
);
}
@@ -0,0 +1,242 @@
import ReactThreeTestRenderer from "@react-three/test-renderer";
import type { ReactThreeTest } from "@react-three/test-renderer";
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Vec3 } from "@/lib/hive/layout";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
import type { HiveViewStore } from "../use-hive-view";
import { HiveSceneContent } from "./hive-scene-content";
import { IDLE_FLOAT, ModelRig } from "./model-rig";
import { SceneKitProvider } from "./scene-kit";
// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM
// portals (drei Html), so they are replaced by a marker here.
vi.mock("./hive-labels", () => ({
HoloLabel: ({ text }: { text: string }) => <group name={`label:${text}`} />,
}));
const catalog = inlineCatalog();
function hiveState(): HiveState {
return {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", {
frames: frames(10).map((frame) =>
frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame,
),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
}),
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
part("r", "roof"),
],
};
}
interface Rendered {
renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>>;
store: HiveViewStore;
onSelectPart: ReturnType<typeof vi.fn>;
onSelectFrame: ReturnType<typeof vi.fn>;
}
let current: Rendered | null = null;
async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
const state = hiveState();
const layout = hiveLayout(state, catalog);
const store = createHiveViewStore();
const onSelectPart = vi.fn();
const onSelectFrame = vi.fn();
const renderer = await ReactThreeTestRenderer.create(
<HiveViewProvider store={store}>
<SceneKitProvider motion={motion}>
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={(id) => partDisplayName(state, catalog, id)}
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
idle={false}
/>
</SceneKitProvider>
</HiveViewProvider>,
);
current = { renderer, store, onSelectPart, onSelectFrame };
return current;
}
afterEach(async () => {
await current?.renderer.unmount();
current = null;
});
const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance =>
renderer.scene.find((node) => node.props.userData?.hit === id);
const hits = (renderer: Rendered["renderer"]): string[] =>
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
const target = hit(renderer, id);
await renderer.fireEvent(target, "onPointerDown", { pointerType });
await renderer.fireEvent(target, "onClick", { delta: 0 });
}
describe("HiveSceneContent", () => {
it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
const { renderer } = await renderScene();
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
});
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1"));
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#"));
expect(frameHits).toHaveLength(8);
expect(frameHits).not.toContain("b1#1");
expect(frameHits).not.toContain("b1#2");
});
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
expect(store.getState().hoveredId).toBe("b1");
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOut", { pointerType: "mouse" });
expect(store.getState().hoveredId).toBeNull();
});
it("opens a box on click and focuses any other part", async () => {
const { renderer, store } = await renderScene();
await tap(renderer, "b1");
expect(store.getState().openBoxId).toBe("b1");
await act(async () => store.openBox(null));
await tap(renderer, "fe");
expect(store.getState()).toMatchObject({ focusedPartId: "fe", openBoxId: null });
});
it("opens the part sheet when the focused part's content is clicked", async () => {
const { renderer, store, onSelectPart } = await renderScene();
await act(async () => store.focusPart("fe"));
await tap(renderer, "fe");
expect(onSelectPart).toHaveBeenCalledWith("fe");
});
it("does not react to clicks on ghost parts in focus mode", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.focusPart("fe"));
// Ghosts do not raycast; the scene also ignores them if an event arrives.
expect(hit(renderer, "b1").props.raycast).not.toBe(hit(renderer, "fe").props.raycast);
});
it("hovers and selects frames of the open box", async () => {
const { renderer, store, onSelectFrame } = await renderScene();
await act(async () => store.openBox("b1"));
await renderer.fireEvent(hit(renderer, "b1#4"), "onPointerOver", { pointerType: "mouse" });
expect(store.getState().hoveredFrame).toEqual({ boxId: "b1", slot: 4 });
await tap(renderer, "b1#4");
expect(onSelectFrame).toHaveBeenCalledWith("b1", 4);
});
it("focuses the frame feeder of an open box on click", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1"));
await tap(renderer, "ff");
expect(store.getState().focusedPartId).toBe("ff");
});
it("on touch hovers with the first tap and selects with the second", async () => {
const { renderer, store } = await renderScene();
await tap(renderer, "b1", "touch");
expect(store.getState()).toMatchObject({ hoveredId: "b1", openBoxId: null });
await tap(renderer, "b1", "touch");
expect(store.getState().openBoxId).toBe("b1");
});
it("ignores touch pointer-over (taps drive the hover)", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "touch" });
expect(store.getState().hoveredId).toBeNull();
});
it("ignores a click that ended a drag", async () => {
const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerDown", { pointerType: "mouse" });
await renderer.fireEvent(hit(renderer, "b1"), "onClick", { delta: 40 });
expect(store.getState().openBoxId).toBeNull();
});
it("labels the hovered part", async () => {
const { renderer, store } = await renderScene();
await act(async () => store.setHovered("b1"));
expect(renderer.scene.findAll((node) => node.props.name === "label:Zarge · 8 Rähmchen")).toHaveLength(1);
});
it("lifts the hovered part toward its target over a few frames at full", async () => {
const { renderer, store } = await renderScene("full");
await renderer.advanceFrames(1, 1 / 60);
const group = hit(renderer, "b1").parent!;
const before = group.instance.position.y;
await act(async () => store.setHovered("b1"));
await renderer.advanceFrames(1, 1 / 60);
const midway = group.instance.position.y;
await renderer.advanceFrames(30, 1 / 60);
const settled = group.instance.position.y;
expect(midway).toBeGreaterThan(before);
expect(midway).toBeLessThan(settled);
expect(settled - before).toBeCloseTo(0.08, 3);
});
it("jumps to the target at once below full", async () => {
const { renderer, store } = await renderScene("subtle");
await renderer.advanceFrames(1, 1 / 60);
const group = hit(renderer, "b1").parent!;
const before = group.instance.position.y;
await act(async () => store.setHovered("b1"));
await renderer.advanceFrames(1, 1 / 60);
expect(group.instance.position.y - before).toBeCloseTo(0.08, 6);
});
});
describe("ModelRig", () => {
async function renderRig(motion: MotionLevel, idle: boolean) {
const pivot: Vec3 = [0, 1, 0];
const renderer = await ReactThreeTestRenderer.create(
<SceneKitProvider motion={motion}>
<ModelRig getPivot={() => pivot} idle={idle}>
<mesh name="probe" />
</ModelRig>
</SceneKitProvider>,
);
const outer = renderer.scene.children[0];
return { renderer, outer };
}
it("floats the model at full while idle", async () => {
const { renderer, outer } = await renderRig("full", true);
const heights = new Set<number>();
for (let step = 0; step < 6; step += 1) {
await renderer.advanceFrames(1, 0.25);
heights.add(Number(outer.instance.position.y.toFixed(4)));
}
expect(heights.size).toBeGreaterThan(1);
for (const height of heights) expect(Math.abs(height - 1)).toBeLessThanOrEqual(IDLE_FLOAT + 1e-9);
await renderer.unmount();
});
it("keeps the model still at subtle, even when idle is allowed", async () => {
const { renderer, outer } = await renderRig("subtle", true);
for (let step = 0; step < 6; step += 1) {
await renderer.advanceFrames(1, 0.25);
expect(outer.instance.position.y).toBe(1);
expect(outer.instance.rotation.y).toBe(0);
}
await renderer.unmount();
});
});
@@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThree } from "@react-three/fiber";
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { focusedIdOf, useHiveView } from "../use-hive-view";
import type { HoveredFrame } from "../use-hive-view";
import { CameraRig } from "./camera-rig";
import { FramePlate } from "./frame-plate";
import { ModelRig } from "./model-rig";
import { buildSceneModel, sceneLabels } from "./scene-model";
import type { SceneLabel } from "./scene-model";
import { setCanvasCursor } from "./scene-mutations";
import { StackPart } from "./stack-part";
import type { NodeHandlers } from "./stack-part";
import { frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion";
import type { HiveMotionTargets } from "./use-hive-motion";
import { useTouchSelect } from "./use-touch-select";
// Everything inside the canvas that knows about the hive: parts, inserts,
// frames, labels, camera and turning; it must sit inside a SceneKitProvider.
// Pointer events go to the shared view store (spec 9.1), so the outline, the
// SVG and the scene stay in step.
/** A press that moved further than this is a drag, not a click. */
export const CLICK_SLOP_PX = 8;
export interface HiveSceneContentProps {
layout: HiveLayout;
state: HiveState;
catalog: HiveCatalog;
partName: (id: string) => string;
/** Content of a focused part was clicked (opens its sheet). */
onSelectPart: (id: string) => void;
/** A frame of the open box was clicked (opens the frame sheet). */
onSelectFrame: (boxId: string, slot: number) => void;
/** Idle motion may run (see shouldIdle). */
idle: boolean;
}
const partKey = (id: string): string => `part:${id}`;
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
if (key.startsWith("part:")) return { kind: "part", id: key.slice(5) };
if (key.startsWith("frame:")) {
const rest = key.slice(6);
const split = rest.lastIndexOf("#");
const slot = Number(rest.slice(split + 1));
if (split < 0 || !Number.isInteger(slot)) return null;
return { kind: "frame", boxId: rest.slice(0, split), slot };
}
return null;
}
function labelsFor(labels: SceneLabel[], id: string): SceneLabel[] {
return labels.filter((label) => label.anchor.kind === "node" && label.anchor.id === id);
}
function frameLabel(labels: SceneLabel[], boxId: string, slot: number): SceneLabel | undefined {
return labels.find(
(label) => label.anchor.kind === "frame" && label.anchor.boxId === boxId && label.anchor.slot === slot,
);
}
export function HiveSceneContent({
layout,
state,
catalog,
partName,
onSelectPart,
onSelectFrame,
idle,
}: HiveSceneContentProps) {
const view = useHiveView();
const focusedId = focusedIdOf(view);
const gl = useThree((root) => root.gl);
// The frame whose sheet was opened stays lifted until the focus changes.
const [selectedFrame, setSelectedFrame] = useState<HoveredFrame | null>(null);
const [seenFocus, setSeenFocus] = useState(focusedId);
if (seenFocus !== focusedId) {
setSeenFocus(focusedId);
setSelectedFrame(null);
}
const model = useMemo(() => buildSceneModel(layout, state, catalog, partName), [layout, state, catalog, partName]);
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
const targets: HiveMotionTargets = useMemo(() => hiveMotionTargets(layout, motionView), [layout, motionView]);
const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]);
const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer);
const selectPart = (id: string) => {
const part = model.get(id);
if (!part) return;
if (focusedId === id) {
// A click on the focused part is a click on its content: open its sheet.
if (part.kind !== "BOX") onSelectPart(id);
return;
}
view.setHovered(null);
if (part.kind === "BOX") view.openBox(id);
else view.focusPart(id);
};
const selectFrame = (boxId: string, slot: number) => {
setSelectedFrame({ boxId, slot });
onSelectFrame(boxId, slot);
};
const hoverKey = (key: string | null) => {
const parsed = key === null ? null : parseKey(key);
if (!parsed) {
view.setHovered(null);
view.setHoveredFrame(null);
} else if (parsed.kind === "part") {
view.setHovered(parsed.id);
} else {
view.setHoveredFrame({ boxId: parsed.boxId, slot: parsed.slot });
}
};
const touch = useTouchSelect({
onHover: hoverKey,
onSelect: (key) => {
const parsed = parseKey(key);
if (parsed?.kind === "part") selectPart(parsed.id);
else if (parsed?.kind === "frame") selectFrame(parsed.boxId, parsed.slot);
},
});
// A tap into empty space (hover cleared) or a new focus starts the two-tap cycle over.
const nothingHovered = view.hoveredId === null && view.hoveredFrame === null;
useEffect(() => {
if (nothingHovered) touch.reset();
}, [nothingHovered, focusedId, touch]);
const pointerType = useRef("mouse");
const handlersFor = (key: string): NodeHandlers => ({
onPointerOver(event) {
event.stopPropagation();
if (event.pointerType === "touch") return;
hoverKey(key);
setCursor(true);
},
onPointerOut(event) {
if (event.pointerType === "touch") return;
const parsed = parseKey(key);
if (parsed?.kind === "part" && view.hoveredId === parsed.id) view.setHovered(null);
if (
parsed?.kind === "frame" &&
view.hoveredFrame?.boxId === parsed.boxId &&
view.hoveredFrame.slot === parsed.slot
) {
view.setHoveredFrame(null);
}
setCursor(false);
},
onPointerDown(event) {
pointerType.current = event.pointerType ?? "mouse";
},
onClick(event) {
event.stopPropagation();
if ((event.delta ?? 0) > CLICK_SLOP_PX) return;
touch.tap(key, pointerType.current);
},
});
const pivot = useRef<Vec3>(restBounds(layout).center);
const setPivot = useCallback((center: Vec3) => {
pivot.current = center;
}, []);
const getPivot = useCallback(() => pivot.current, []);
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
return (
<>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} />
<ModelRig getPivot={getPivot} idle={idle}>
{layout.parts.map((placed) => {
const part = model.get(placed.id);
const target = targets.nodes.get(placed.id);
if (!part || !target) return null;
const frames = layout.frames.filter((frame) => frame.boxId === placed.id);
return (
<StackPart
key={placed.id}
part={part}
target={target}
focused={focusedId === placed.id}
handlers={handlersFor(partKey(placed.id))}
labels={labelsFor(labels, placed.id)}
hintFrames={placed.kind === "BOX" ? frames : undefined}
>
{insertsOf(placed).map((insert) => {
const insertPart = model.get(insert.id);
const insertTarget = targets.nodes.get(insert.id);
if (!insertPart || !insertTarget) return null;
return (
<StackPart
key={insert.id}
part={insertPart}
target={insertTarget}
focused={focusedId === insert.id}
handlers={handlersFor(partKey(insert.id))}
labels={labelsFor(labels, insert.id)}
/>
);
})}
{target.content
? frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null;
return (
<FramePlate
key={frame.slot}
frame={frame}
hostCenter={placed.center}
target={frameTarget}
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
label={frameLabel(labels, frame.boxId, frame.slot)}
/>
);
})
: null}
</StackPart>
);
})}
</ModelRig>
</>
);
}
@@ -0,0 +1,372 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing";
import {
AdditiveBlending,
BufferGeometry,
CanvasTexture,
Float32BufferAttribute,
NormalBlending,
SRGBColorSpace,
ShaderMaterial,
} from "three";
import type { Points } from "three";
import { useSceneKit } from "./scene-kit";
import { setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme";
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
// cool light from behind, glossy dark floor with a faint hexagon grid, a few
// slow bokeh particles, bloom at half resolution, vignette. Cool light only:
// warm colours belong to the content (owner 2026-10-16).
/** Objects on this layer are seen by the camera but not by the floor reflection. */
const OVERLAY_LAYER = 1;
const FLOOR_SIZE = 240;
const PARTICLE_COUNT = 42;
export type StageTier = "high" | "low";
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
export function detectStageTier(): StageTier {
if (typeof window === "undefined") return "low";
const cores = navigator.hardwareConcurrency ?? 4;
const coarse = window.matchMedia?.("(pointer: coarse)").matches ?? false;
return cores >= 6 && !coarse ? "high" : "low";
}
function backdropTexture(theme: HoloTheme): CanvasTexture | null {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext("2d");
if (!context) return null;
const base = `#${theme.background.getHexString()}`;
const vertical = context.createLinearGradient(0, 0, 0, 512);
vertical.addColorStop(0, "#081633");
vertical.addColorStop(0.55, base);
vertical.addColorStop(1, "#040814");
context.fillStyle = vertical;
context.fillRect(0, 0, 512, 512);
// Cool rim light glowing behind the hive.
const glow = context.createRadialGradient(290, 170, 10, 290, 170, 300);
glow.addColorStop(0, "rgba(70, 150, 255, 0.55)");
glow.addColorStop(0.45, "rgba(40, 100, 220, 0.22)");
glow.addColorStop(1, "rgba(10, 30, 80, 0)");
context.fillStyle = glow;
context.fillRect(0, 0, 512, 512);
// Soft light shafts from the upper left.
context.globalCompositeOperation = "lighter";
for (const [x, width, alpha] of [
[120, 60, 0.05],
[210, 34, 0.04],
[300, 80, 0.035],
] as const) {
const shaft = context.createLinearGradient(x, 0, x + width, 0);
shaft.addColorStop(0, "rgba(120, 180, 255, 0)");
shaft.addColorStop(0.5, `rgba(140, 200, 255, ${alpha})`);
shaft.addColorStop(1, "rgba(120, 180, 255, 0)");
context.save();
context.translate(x, 0);
context.rotate(-0.38);
context.translate(-x, 0);
context.fillStyle = shaft;
context.fillRect(x, -80, width, 560);
context.restore();
}
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
return texture;
}
function Backdrop() {
const { theme } = useSceneKit();
const scene = useThree((state) => state.scene);
useEffect(() => {
const texture = backdropTexture(theme);
setSceneBackground(scene, texture ?? theme.background);
return () => {
setSceneBackground(scene, null);
texture?.dispose();
};
}, [scene, theme]);
return null;
}
const FLOOR_VERTEX = /* glsl */ `
varying vec2 vXZ;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vXZ = world.xz;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const HEX_FRAGMENT = /* glsl */ `
uniform vec3 uGrid;
uniform vec3 uPool;
uniform vec3 uFade;
uniform float uHex;
uniform float uBase;
varying vec2 vXZ;
float hexDistance(vec2 p) {
p = abs(p);
return max(dot(p, normalize(vec2(1.0, 1.7320508))), p.x);
}
void main() {
vec2 uv = vXZ / uHex;
vec2 r = vec2(1.0, 1.7320508);
vec2 h = r * 0.5;
vec2 a = mod(uv, r) - h;
vec2 b = mod(uv - h, r) - h;
vec2 g = dot(a, a) < dot(b, b) ? a : b;
float edge = 0.5 - hexDistance(g);
float width = fwidth(edge) * 1.4;
float line = 1.0 - smoothstep(0.0, width + 0.006, edge);
float distance = length(vXZ);
float fade = 1.0 - smoothstep(3.0, 17.0, distance);
float pool = exp(-distance * distance / 14.0);
vec3 color = uGrid * line * fade * 0.32 + uPool * pool * 0.18;
// uBase > 0: opaque fake floor (low tier) that also fades into the backdrop.
vec3 floorColor = mix(uFade, uFade * 1.6 + uPool * 0.06, pool) + color;
float far = smoothstep(12.0, 45.0, distance);
vec3 outColor = mix(color, mix(floorColor, uFade, far), uBase);
gl_FragColor = vec4(outColor, 1.0);
}
`;
function hexMaterial(theme: HoloTheme, opaque: boolean): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uGrid: { value: theme.grid.clone() },
uPool: { value: theme.rim.clone() },
uFade: { value: theme.background.clone().multiplyScalar(0.75) },
uHex: { value: 2.4 },
uBase: { value: opaque ? 1 : 0 },
},
vertexShader: FLOOR_VERTEX,
fragmentShader: HEX_FRAGMENT,
transparent: !opaque,
depthWrite: opaque,
blending: opaque ? NormalBlending : AdditiveBlending,
});
}
const FADE_FRAGMENT = /* glsl */ `
uniform vec3 uFade;
varying vec2 vXZ;
void main() {
float far = smoothstep(12.0, 45.0, length(vXZ));
gl_FragColor = vec4(uFade, far);
}
`;
function Floor({ tier }: { tier: StageTier }) {
const { theme } = useSceneKit();
const camera = useThree((state) => state.camera);
const materials = useMemo(
() => ({
grid: hexMaterial(theme, tier === "low"),
fade: new ShaderMaterial({
uniforms: { uFade: { value: theme.background.clone().multiplyScalar(0.75) } },
vertexShader: FLOOR_VERTEX,
fragmentShader: FADE_FRAGMENT,
transparent: true,
depthWrite: false,
}),
}),
[theme, tier],
);
useEffect(() => {
camera.layers.enable(OVERLAY_LAYER);
return () => {
materials.grid.dispose();
materials.fade.dispose();
};
}, [camera, materials]);
if (tier === "low") {
return (
<mesh rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
</mesh>
);
}
return (
<>
<mesh rotation-x={-Math.PI / 2} renderOrder={-3}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<MeshReflectorMaterial
resolution={512}
blur={[420, 140]}
mixBlur={0.9}
mixStrength={3.2}
mixContrast={1.1}
mirror={0.75}
depthScale={0.5}
minDepthThreshold={0.4}
maxDepthThreshold={1.3}
color="#060b18"
roughness={0.9}
metalness={0.55}
/>
</mesh>
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
<mesh
rotation-x={-Math.PI / 2}
position-y={0.004}
material={materials.grid}
renderOrder={-2}
layers={OVERLAY_LAYER}
>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
</mesh>
<mesh
rotation-x={-Math.PI / 2}
position-y={0.008}
material={materials.fade}
renderOrder={-1}
layers={OVERLAY_LAYER}
>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
</mesh>
</>
);
}
const PARTICLE_VERTEX = /* glsl */ `
attribute float aSize;
attribute float aPhase;
uniform float uTime;
uniform float uPixelRatio;
varying float vSoft;
varying float vPhase;
void main() {
vec3 p = position;
p.y += sin(uTime * 0.11 + aPhase) * 0.35;
p.x += cos(uTime * 0.07 + aPhase * 1.7) * 0.25;
vec4 view = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = aSize * uPixelRatio * (9.0 / max(-view.z, 0.5));
vSoft = clamp(aSize / 18.0, 0.0, 1.0);
vPhase = aPhase;
gl_Position = projectionMatrix * view;
}
`;
const PARTICLE_FRAGMENT = /* glsl */ `
uniform vec3 uCool;
uniform vec3 uWhite;
uniform float uTime;
varying float vSoft;
varying float vPhase;
void main() {
float d = length(gl_PointCoord - 0.5) * 2.0;
// Small motes are crisp, large ones are soft bokeh discs with a brighter rim.
float disc = 1.0 - smoothstep(0.55 - vSoft * 0.35, 1.0, d);
float rim = smoothstep(0.6, 0.9, d) * (1.0 - smoothstep(0.9, 1.0, d)) * vSoft;
float twinkle = 0.75 + 0.25 * sin(uTime * 0.6 + vPhase * 3.0);
vec3 color = mix(uWhite, uCool, vSoft);
float alpha = (disc * (0.9 - vSoft * 0.6) + rim * 0.35) * twinkle;
gl_FragColor = vec4(color * alpha, 1.0);
}
`;
/** Deterministic placement, so the stage looks the same on every visit (Park-Miller). */
function seededRandom(start: number): () => number {
let seed = start;
return () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
}
function Particles() {
const { theme } = useSceneKit();
const dpr = useThree((state) => state.viewport.dpr);
const points = useRef<Points>(null);
const { geometry, material } = useMemo(() => {
const next = seededRandom(7);
const positions: number[] = [];
const sizes: number[] = [];
const phases: number[] = [];
for (let index = 0; index < PARTICLE_COUNT; index += 1) {
positions.push((next() - 0.5) * 22, 0.4 + next() * 9, (next() - 0.7) * 18);
sizes.push(next() < 0.7 ? 2 + next() * 5 : 10 + next() * 14);
phases.push(next() * Math.PI * 2);
}
const built = new BufferGeometry();
built.setAttribute("position", new Float32BufferAttribute(positions, 3));
built.setAttribute("aSize", new Float32BufferAttribute(sizes, 1));
built.setAttribute("aPhase", new Float32BufferAttribute(phases, 1));
const shader = new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: 1 },
uCool: { value: theme.edge.clone() },
uWhite: { value: theme.hot.clone() },
},
vertexShader: PARTICLE_VERTEX,
fragmentShader: PARTICLE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
return { geometry: built, material: shader };
}, [theme]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame((state) => {
setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPixelRatio", dpr);
});
return <points ref={points} geometry={geometry} material={material} renderOrder={5} raycast={() => {}} />;
}
function Lights() {
const { theme } = useSceneKit();
return (
<>
<ambientLight intensity={0.55} color="#cfe6ff" />
<directionalLight position={[3, 9, 7]} intensity={1.5} color="#ffffff" />
<directionalLight position={[-5, 5, -7]} intensity={1.3} color={theme.rim} />
</>
);
}
function Effects({ tier }: { tier: StageTier }) {
return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
<Bloom
mipmapBlur
luminanceThreshold={0.7}
luminanceSmoothing={0.28}
intensity={0.85}
radius={0.72}
resolutionScale={0.5}
/>
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
<Vignette offset={0.26} darkness={0.7} />
</EffectComposer>
);
}
export function HiveStage({ tier }: { tier: StageTier }) {
return (
<>
<Backdrop />
<Lights />
<Floor tier={tier} />
<Particles />
<Effects tier={tier} />
</>
);
}
@@ -0,0 +1,250 @@
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
import type { HoloTheme } from "./theme";
// Hand-written shaders instead of transmission: a fake studio environment
// (white soft box above, blue rim light behind, faint cool bounce below) with
// fresnel gives the glass look at a fraction of the cost (spec 9.9).
const GLASS_VERTEX = /* glsl */ `
varying vec3 vNormalW;
varying vec3 vViewDir;
void main() {
vec4 world = modelMatrix * vec4(position, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const GLASS_FRAGMENT = /* glsl */ `
uniform vec3 uTint;
uniform vec3 uRim;
uniform vec3 uBounce;
uniform float uOpacity;
uniform float uFrost;
uniform float uGlow;
varying vec3 vNormalW;
varying vec3 vViewDir;
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
if (!gl_FrontFacing) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
float sky = smoothstep(-0.1, 0.9, r.y);
float ground = smoothstep(0.05, -0.7, r.y);
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel);
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45;
if (!gl_FrontFacing) {
// Faintly frosted inner faces.
alpha *= 0.6;
color = mix(color, uTint * 0.45, 0.35);
}
color *= 0.78 + 0.22 * uGlow;
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
}
`;
export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number;
}
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uTint: { value: theme.tint.clone() },
uRim: { value: theme.rim.clone() },
uBounce: { value: theme.bounce.clone() },
uOpacity: { value: 1 },
uFrost: { value: options.frost ?? 0 },
uGlow: { value: 1 },
},
vertexShader: GLASS_VERTEX,
fragmentShader: GLASS_FRAGMENT,
transparent: true,
depthWrite: false,
side: DoubleSide,
blending: NormalBlending,
});
}
const EDGE_VERTEX = /* glsl */ `
attribute float aHot;
attribute float aStrength;
varying float vHot;
varying float vStrength;
void main() {
vHot = aHot;
vStrength = aStrength;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const EDGE_FRAGMENT = /* glsl */ `
uniform vec3 uCyan;
uniform vec3 uHot;
uniform float uIntensity;
uniform float uOpacity;
varying float vHot;
varying float vStrength;
void main() {
float hot = vHot * vHot;
vec3 color = mix(uCyan, uHot, hot);
float energy = uIntensity * vStrength * (1.0 + hot * 1.6);
gl_FragColor = vec4(color * energy * uOpacity, 1.0);
}
`;
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uCyan: { value: theme.edge.clone() },
uHot: { value: theme.hot.clone() },
uIntensity: { value: 1.6 },
uOpacity: { value: 1 },
},
vertexShader: EDGE_VERTEX,
fragmentShader: EDGE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
}
const GRID_VERTEX = /* glsl */ `
varying vec3 vLocal;
void main() {
vLocal = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const GRID_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uCell;
uniform float uBars;
uniform float uOpacity;
varying vec3 vLocal;
float gridLine(float coord) {
float scaled = coord / uCell;
float distance = abs(fract(scaled - 0.5) - 0.5) / max(fwidth(scaled), 1e-4);
return 1.0 - min(distance / 1.2, 1.0);
}
void main() {
float along = gridLine(vLocal.z);
float across = uBars > 0.5 ? 0.0 : gridLine(vLocal.x);
float line = max(along, across);
gl_FragColor = vec4(uColor * (0.25 + line * 1.1) * uOpacity, 1.0);
}
`;
export interface GridOptions {
/** Line spacing in layout units. */
cell: number;
/** Only parallel bars (queen excluder) instead of a mesh. */
bars?: boolean;
}
/** Screens, Bausperre and excluder: a fine glowing mesh, additive. */
export function createGridMaterial(theme: HoloTheme, options: GridOptions): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uColor: { value: theme.edge.clone().multiplyScalar(0.4) },
uCell: { value: options.cell },
uBars: { value: options.bars ? 1 : 0 },
uOpacity: { value: 1 },
},
vertexShader: GRID_VERTEX,
fragmentShader: GRID_FRAGMENT,
transparent: true,
depthWrite: false,
side: DoubleSide,
blending: AdditiveBlending,
});
}
const LIQUID_VERTEX = /* glsl */ `
uniform float uTime;
uniform float uWobble;
varying vec3 vNormalW;
varying vec3 vViewDir;
varying float vTop;
varying float vDepth;
void main() {
vec3 p = position;
vTop = step(0.49, p.y);
vDepth = 0.5 - p.y;
if (p.y > 0.49) {
p.y += uWobble * (0.6 * sin(p.x * 9.0 + uTime * 1.7) + 0.4 * sin(p.z * 7.0 - uTime * 1.3));
}
vec4 world = modelMatrix * vec4(p, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz;
gl_Position = projectionMatrix * viewMatrix * world;
}
`;
const LIQUID_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uDeep;
uniform float uGlow;
uniform float uOpacity;
uniform float uAlpha;
varying vec3 vNormalW;
varying vec3 vViewDir;
varying float vTop;
varying float vDepth;
void main() {
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir);
float fresnel = pow(1.0 - clamp(abs(dot(n, v)), 0.0, 1.0), 2.0);
// Deep amber below, bright gold toward the surface and the rims.
float lightness = clamp(0.25 + 0.55 * (1.0 - vDepth) + 0.4 * fresnel, 0.0, 1.0);
vec3 color = mix(uDeep, uColor, lightness);
color += uColor * vTop * 0.45;
float sparkle = pow(max(dot(reflect(-v, n), normalize(vec3(-0.3, 0.9, 0.2))), 0.0), 40.0);
color += vec3(1.0, 0.95, 0.8) * sparkle * vTop * 1.4;
float alpha = uAlpha * (0.62 + 0.38 * max(fresnel, vTop));
gl_FragColor = vec4(color * uGlow, alpha * uOpacity);
}
`;
export interface LiquidOptions {
color: Color;
deep: Color;
/** Brightness; above 1 the liquid blooms softly. */
glow: number;
alpha: number;
}
/** Glowing liquid with a gently moving surface (wobble set per frame at motion `full`). */
export function createLiquidMaterial(options: LiquidOptions): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uWobble: { value: 0 },
uColor: { value: options.color.clone() },
uDeep: { value: options.deep.clone() },
uGlow: { value: options.glow },
uOpacity: { value: 1 },
uAlpha: { value: options.alpha },
},
vertexShader: LIQUID_VERTEX,
fragmentShader: LIQUID_FRAGMENT,
transparent: true,
depthWrite: false,
});
}
/** Sets the per-node uniforms shared by every material here. */
export function setOpacity(material: ShaderMaterial, opacity: number): void {
const uniform = material.uniforms.uOpacity;
if (uniform) uniform.value = opacity;
}
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useRef } from "react";
import type { MutableRefObject, ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { Group } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { useSceneKit } from "./scene-kit";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion";
// Turning and resting motion of the whole model (spec 9.4):
// - horizontal drag (mouse, pen, one finger) turns it ±60° around the
// vertical axis through the camera target; 4 s after the last drag it
// springs back. Vertical swipes scroll the page (touch-action: pan-y).
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export const IDLE_FLOAT = 0.04;
export const IDLE_PERIOD_SECONDS = 6;
export const SPRING_BACK_MS = 4000;
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
export const IDLE_FRAME_MS = 33;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const input = useRef<RigInput>({ turn: 0, tiltX: 0, tiltY: 0, dragging: false });
useEffect(() => {
const element = gl.domElement as HTMLElement | undefined;
if (!element || typeof element.addEventListener !== "function") return;
let press: { x: number; y: number; turn: number; id: number } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
const onDown = (event: PointerEvent) => {
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId };
if (springBack) clearTimeout(springBack);
};
const onMove = (event: PointerEvent) => {
const rect = element.getBoundingClientRect();
if (press && press.id === event.pointerId) {
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.current.dragging = true;
}
if (input.current.dragging) {
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
invalidate();
}
return;
}
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.current.tiltY = nx * MAX_TILT;
input.current.tiltX = ny * MAX_TILT * 0.5;
invalidate();
}
};
const onUp = () => {
if (input.current.dragging) {
springBack = setTimeout(() => {
input.current.turn = 0;
invalidate();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
setTimeout(() => {
input.current.dragging = false;
}, 0);
};
const onLeave = () => {
input.current.tiltX = 0;
input.current.tiltY = 0;
invalidate();
};
element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp);
element.addEventListener("pointercancel", onUp);
element.addEventListener("pointerleave", onLeave);
return () => {
if (springBack) clearTimeout(springBack);
element.removeEventListener("pointerdown", onDown);
element.removeEventListener("pointermove", onMove);
element.removeEventListener("pointerup", onUp);
element.removeEventListener("pointercancel", onUp);
element.removeEventListener("pointerleave", onLeave);
};
}, [gl, invalidate, enabledTilt]);
return input;
}
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */
function useIdleTicker(active: boolean): void {
const invalidate = useThree((state) => state.invalidate);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (active) invalidate();
return () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
}, [active, invalidate]);
useFrame(() => {
if (!active || timer.current !== null) return;
timer.current = setTimeout(() => {
timer.current = null;
invalidate();
}, IDLE_FRAME_MS);
});
}
export interface ModelRigProps {
/** Current camera target; the model turns around it. */
getPivot: () => Vec3;
/** Idle motion may run (motion `full`, card visible, tab visible). */
idle: boolean;
children: ReactNode;
}
export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const full = motion === "full";
const input = useRigInput(full);
const outer = useRef<Group>(null);
const inner = useRef<Group>(null);
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
useIdleTicker(idle && full);
useFrame((_, delta) => {
if (!outer.current || !inner.current) return;
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const now = current.current;
// Idle time only advances while idle frames run, so the float resumes smoothly.
now.time += dt;
const goal = input.current;
// A drag follows the finger directly at every level; only the way back is animated.
now.turn = goal.dragging ? goal.turn : stepToward(now.turn, goal.turn, dt, full);
now.tiltX = stepToward(now.tiltX, full ? goal.tiltX : 0, dt, full);
now.tiltY = stepToward(now.tiltY, full ? goal.tiltY : 0, dt, full);
const floating = idle && full;
now.float = floating
? IDLE_FLOAT * Math.sin((now.time / IDLE_PERIOD_SECONDS) * Math.PI * 2)
: stepToward(now.float, 0, dt, full);
const [px, py, pz] = getPivot();
outer.current.position.set(px, py + now.float, pz);
outer.current.rotation.set(now.tiltX, now.turn + now.tiltY, 0);
inner.current.position.set(-px, -py, -pz);
const moving =
now.turn !== goal.turn ||
now.tiltX !== (full ? goal.tiltX : 0) ||
now.tiltY !== (full ? goal.tiltY : 0) ||
(!floating && now.float !== 0);
if (moving) invalidate();
});
return (
<group ref={outer}>
<group ref={inner}>{children}</group>
</group>
);
}
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout";
import type { Vec3 } from "@/lib/hive/layout";
import type { HivePartKind } from "@/lib/hive/types";
import {
buildEdgeGeometry,
buildGlassGeometry,
feederChambers,
frameShapeSpec,
partShapeSpec,
shapeKey,
} from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry";
const BOX: Vec3 = [5.25, 2.3, 4.3];
const FLOOR: Vec3 = [5.25, 1.2, 4.3];
/** Outer extent of every slab, to check nothing sticks out of the footprint. */
function extent(spec: PartShapeSpec): { min: Vec3; max: Vec3 } {
const min: Vec3 = [Infinity, Infinity, Infinity];
const max: Vec3 = [-Infinity, -Infinity, -Infinity];
for (const slab of spec.slabs) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], slab.at[axis] - slab.size[axis] / 2);
max[axis] = Math.max(max[axis], slab.at[axis] + slab.size[axis] / 2);
}
}
return { min, max };
}
describe("partShapeSpec", () => {
it("builds a box as four glass walls with hand grips on both short sides", () => {
const spec = partShapeSpec("BOX", BOX);
expect(spec.slabs).toHaveLength(4);
const gripEdges = spec.edges.filter((edge) => edge.tag === "grip");
expect(gripEdges.length).toBeGreaterThan(8);
// Short sides are the ±x faces (430 mm wide).
expect(gripEdges.some((edge) => edge.a[0] > BOX[0] / 2 - 0.1)).toBe(true);
expect(gripEdges.some((edge) => edge.a[0] < -BOX[0] / 2 + 0.1)).toBe(true);
expect(gripEdges.every((edge) => Math.abs(edge.a[2]) < BOX[2] / 2)).toBe(true);
});
it("marks the inner rebate (Falz) where the frame lugs rest", () => {
const spec = partShapeSpec("BOX", BOX);
expect(spec.edges.filter((edge) => edge.tag === "rebate").length).toBeGreaterThanOrEqual(2);
});
it("keeps the Liebig floor inside its footprint: no landing board, no legs", () => {
const { min, max } = extent(partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" }));
expect(min[0]).toBeGreaterThanOrEqual(-FLOOR[0] / 2 - 1e-9);
expect(max[0]).toBeLessThanOrEqual(FLOOR[0] / 2 + 1e-9);
expect(min[1]).toBeGreaterThanOrEqual(-FLOOR[1] / 2 - 1e-9);
expect(max[2]).toBeLessThanOrEqual(FLOOR[2] / 2 + 0.05);
});
it("gives the floor a top grid, an entrance slider at the front and an inspection flap at the back", () => {
const spec = partShapeSpec("FLOOR", FLOOR, {});
expect(spec.grids).toHaveLength(1);
expect(spec.grids[0].at[1]).toBeGreaterThan(0);
expect(spec.edges.some((edge) => edge.tag === "entrance" && edge.a[2] > FLOOR[2] / 2 - 0.1)).toBe(true);
expect(spec.edges.some((edge) => edge.tag === "flap" && edge.a[2] < -FLOOR[2] / 2 + 0.1)).toBe(true);
});
it("puts the grid 40 mm below the floor top, where the layout lays the Bausperre", () => {
expect(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]).toBeCloseTo(FLOOR[1] / 2 - FLOOR_GRID_DEPTH, 9);
});
it("slides the varroa board out to the back when it is out", () => {
const trayIn = partShapeSpec("FLOOR", FLOOR, { varroaTray: "IN" }).slabs.find((slab) => slab.tag === "tray")!;
const trayOut = partShapeSpec("FLOOR", FLOOR, { varroaTray: "OUT" }).slabs.find((slab) => slab.tag === "tray")!;
expect(trayOut.at[2]).toBeLessThan(trayIn.at[2] - 1);
expect(trayIn.at[1]).toBeLessThan(partShapeSpec("FLOOR", FLOOR, {}).grids[0].at[1]);
});
it("narrows the entrance slider opening with the entrance state", () => {
const opening = (entrance: string) =>
partShapeSpec("FLOOR", FLOOR, { entrance }).edges.filter((edge) => edge.tag === "opening");
const width = (edges: ReturnType<typeof opening>) =>
Math.max(...edges.map((edge) => edge.a[0])) - Math.min(...edges.map((edge) => edge.a[0]));
expect(width(opening("NARROW"))).toBeLessThan(width(opening("OPEN")));
expect(opening("CLOSED")).toHaveLength(0);
});
it("gives the roof a slight overhang and the lid a feed hole", () => {
const roof = extent(partShapeSpec("ROOF", [5.25, 0.6, 4.3]));
expect(roof.max[0]).toBeGreaterThan(5.25 / 2);
expect(roof.max[0]).toBeLessThan(5.25 / 2 + 0.3);
expect(partShapeSpec("INNER_LID", [5.25, 0.35, 4.3]).edges.some((edge) => edge.tag === "hole")).toBe(true);
});
it("returns a shape for every kind", () => {
const kinds: HivePartKind[] = [
"FLOOR",
"BOX",
"DIVIDER_BOARD",
"QUEEN_EXCLUDER",
"FEEDER",
"COVER_SHEET",
"PROPOLIS_SCREEN",
"INNER_LID",
"INSULATION",
"ROOF",
"FLOOR_INSERT",
"EVAPORATOR",
"FRAME_FEEDER",
];
for (const kind of kinds) {
const spec = partShapeSpec(kind, [2, 0.5, 2]);
expect(spec.slabs.length + spec.grids.length).toBeGreaterThan(0);
expect(spec.edges.length).toBeGreaterThan(0);
}
});
it("keys the shape cache by kind, size and the state that changes the shape", () => {
expect(shapeKey("FLOOR", FLOOR, { varroaTray: "OUT" })).not.toBe(shapeKey("FLOOR", FLOOR, { varroaTray: "IN" }));
expect(shapeKey("BOX", BOX, { anything: 1 })).toBe(shapeKey("BOX", BOX, {}));
});
});
describe("feederChambers", () => {
it("splits the feeder into one chamber per level field", () => {
const chambers = feederChambers([5.2, 0.7, 4.2], 2);
expect(chambers).toHaveLength(2);
expect(chambers[0].center[0]).toBeLessThan(chambers[1].center[0]);
expect(chambers[0].size[1]).toBeGreaterThan(0.3);
});
});
describe("frameShapeSpec", () => {
const size: Vec3 = [0.25, 2.2, 3.9];
it("has a long top bar with lugs that reach into the rebate", () => {
const spec = frameShapeSpec(size, "BROOD");
const top = spec.bars.find((bar) => bar.tag === "topBar")!;
expect(top.size[2]).toBeGreaterThan(size[2]);
});
it("has a comb for built frames, a thin one for foundation and none for the open drone frame", () => {
expect(frameShapeSpec(size, "HONEY").comb!.size[0]).toBeGreaterThan(frameShapeSpec(size, "FOUNDATION").comb!.size[0]);
expect(frameShapeSpec(size, "DRONE_FRAME").comb).toBeNull();
expect(frameShapeSpec(size, "DIVIDER").comb).not.toBeNull();
});
});
describe("geometry builders", () => {
it("merges the glass slabs into one geometry", () => {
const geometry = buildGlassGeometry(partShapeSpec("BOX", BOX).slabs);
expect(geometry.getAttribute("position").count).toBeGreaterThan(100);
geometry.dispose();
});
it("returns an empty geometry for no slabs (a box with only empty slots)", () => {
const geometry = buildGlassGeometry([]);
expect(geometry.getAttribute("position")).toBeUndefined();
geometry.dispose();
});
it("builds edge tubes with a hot attribute that is 1 at the corners", () => {
const geometry = buildEdgeGeometry(partShapeSpec("BOX", BOX).edges);
const hot = geometry.getAttribute("aHot");
expect(hot).toBeDefined();
let max = 0;
let min = 1;
for (let index = 0; index < hot.count; index += 1) {
max = Math.max(max, hot.getX(index));
min = Math.min(min, hot.getX(index));
}
expect(max).toBe(1);
expect(min).toBe(0);
geometry.dispose();
});
});
@@ -0,0 +1,625 @@
import { BufferGeometry, CylinderGeometry, Float32BufferAttribute, PlaneGeometry } from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout";
import type { Vec3 } from "@/lib/hive/layout";
import type { FrameType, HivePart, HivePartKind } from "@/lib/hive/types";
// Shapes of the parts in local coordinates (centre at 0, layout units,
// x = length 525 mm, y = height, z = width 430 mm; front = +z, short sides ±x).
// The specs are pure data so the owner's details (Liebig Zander proportions,
// hand grips, rebate, Wanderboden without landing board) are testable.
export interface Slab {
size: Vec3;
at: Vec3;
radius: number;
shape?: "box" | "cylinder";
tag?: string;
}
export interface EdgeSegment {
a: Vec3;
b: Vec3;
/** 1 = white-hot end (corner), 0 = plain cyan. */
hotA: number;
hotB: number;
/** Brightness factor, 1 for outer edges, lower for inner light lines. */
strength: number;
tag?: string;
}
export interface GridPanel {
/** Horizontal panel: size along x and z. */
size: [number, number];
at: Vec3;
cell: number;
bars: boolean;
}
export interface PartShapeSpec {
slabs: Slab[];
edges: EdgeSegment[];
grids: GridPanel[];
frost: number;
}
type PartState = HivePart["state"];
const WALL = 0.2;
const EDGE_RADIUS = 0.022;
function segment(a: Vec3, b: Vec3, strength: number, hot = 0, tag?: string): EdgeSegment {
return { a, b, hotA: hot, hotB: hot, strength, ...(tag ? { tag } : {}) };
}
/** The 12 edges of a box, white-hot at its corners. */
function boxEdges(size: Vec3, at: Vec3 = [0, 0, 0], strength = 1, hot = 1, tag?: string): EdgeSegment[] {
const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2];
const corner = (sx: number, sy: number, sz: number): Vec3 => [at[0] + sx * hx, at[1] + sy * hy, at[2] + sz * hz];
const edges: EdgeSegment[] = [];
for (const sy of [-1, 1]) {
for (const sz of [-1, 1]) edges.push(segment(corner(-1, sy, sz), corner(1, sy, sz), strength, hot, tag));
for (const sx of [-1, 1]) edges.push(segment(corner(sx, sy, -1), corner(sx, sy, 1), strength, hot, tag));
}
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) edges.push(segment(corner(sx, -1, sz), corner(sx, 1, sz), strength, hot, tag));
}
return edges;
}
/** Horizontal rectangle at height y. */
function rectXZ(halfX: number, halfZ: number, y: number, strength: number, hot = 0, tag?: string): EdgeSegment[] {
const p = (sx: number, sz: number): Vec3 => [sx * halfX, y, sz * halfZ];
return [
segment(p(-1, -1), p(1, -1), strength, hot, tag),
segment(p(1, -1), p(1, 1), strength, hot, tag),
segment(p(1, 1), p(-1, 1), strength, hot, tag),
segment(p(-1, 1), p(-1, -1), strength, hot, tag),
];
}
type Axis = 0 | 1 | 2;
/** Closed loop of points in the plane spanned by axes u and v around `center`. */
function loop(points: [number, number][], center: Vec3, u: Axis, w: Axis, strength: number, tag?: string): EdgeSegment[] {
const toVec = ([pu, pw]: [number, number]): Vec3 => {
const out: Vec3 = [center[0], center[1], center[2]];
out[u] += pu;
out[w] += pw;
return out;
};
return points.map((point, index) => segment(toVec(point), toVec(points[(index + 1) % points.length]), strength, 0, tag));
}
function roundedRectPoints(width: number, height: number, radius: number, steps = 5): [number, number][] {
const r = Math.min(radius, width / 2, height / 2);
const hw = width / 2 - r;
const hh = height / 2 - r;
const corners: [number, number, number][] = [
[hw, hh, 0],
[-hw, hh, Math.PI / 2],
[-hw, -hh, Math.PI],
[hw, -hh, (3 * Math.PI) / 2],
];
const points: [number, number][] = [];
for (const [cx, cy, start] of corners) {
for (let step = 0; step <= steps; step += 1) {
const angle = start + (step / steps) * (Math.PI / 2);
points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]);
}
}
return points;
}
function circlePoints(radius: number, steps = 32): [number, number][] {
return Array.from({ length: steps }, (_, index) => {
const angle = (index / steps) * Math.PI * 2;
return [radius * Math.cos(angle), radius * Math.sin(angle)] as [number, number];
});
}
/** Four glass walls of a body with an open top and bottom. */
function walls(size: Vec3, wall = WALL, radius = EDGE_RADIUS): Slab[] {
const [sx, sy, sz] = size;
return [
{ size: [sx, sy, wall], at: [0, 0, sz / 2 - wall / 2], radius },
{ size: [sx, sy, wall], at: [0, 0, -sz / 2 + wall / 2], radius },
{ size: [wall, sy, sz - 2 * wall], at: [sx / 2 - wall / 2, 0, 0], radius },
{ size: [wall, sy, sz - 2 * wall], at: [-sx / 2 + wall / 2, 0, 0], radius },
];
}
/** Thin inner light lines of a hollow body: top and bottom inner rims and inner corners. */
function innerLines(size: Vec3, wall = WALL): EdgeSegment[] {
const [sx, sy, sz] = size;
const ix = sx / 2 - wall;
const iz = sz / 2 - wall;
const lines = [...rectXZ(ix, iz, sy / 2, 0.4, 0.4), ...rectXZ(ix, iz, -sy / 2, 0.18)];
for (const cx of [-1, 1]) {
for (const cz of [-1, 1]) lines.push(segment([cx * ix, -sy / 2, cz * iz], [cx * ix, sy / 2, cz * iz], 0.12));
}
return lines;
}
function boxShape(size: Vec3): PartShapeSpec {
const [sx, sy, sz] = size;
const edges = [...boxEdges(size), ...innerLines(size)];
// Rebate (Falz) on the inner faces of the long walls, where the frame lugs rest.
const ix = sx / 2 - WALL;
for (const side of [-1, 1]) {
edges.push(segment([-ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], [ix, sy / 2 - 0.12, side * (sz / 2 - WALL)], 0.35, 0, "rebate"));
edges.push(segment([-ix, sy / 2, side * (sz / 2 - WALL * 0.45)], [ix, sy / 2, side * (sz / 2 - WALL * 0.45)], 0.25, 0, "rebate"));
}
// Recessed hand grips (Griffmulden) on both short sides (±x faces).
const gripY = sy * 0.14;
for (const side of [-1, 1]) {
edges.push(...loop(roundedRectPoints(1.4, 0.34, 0.16), [side * (sx / 2 + 0.004), gripY, 0], 2, 1, 0.95, "grip"));
edges.push(...loop(roundedRectPoints(1.24, 0.2, 0.09), [side * (sx / 2 - 0.07), gripY, 0], 2, 1, 0.3, "grip"));
}
return { slabs: walls(size), edges, grids: [], frost: 0 };
}
const ENTRANCE_OPENING: Record<string, number> = { OPEN: 1.6, NARROW: 0.55, CLOSED: 0 };
/** Liebig Wanderboden: flat box, stainless grid on top, varroa board from the back, flap, entrance slider. */
function floorShape(size: Vec3, state: PartState): PartShapeSpec {
const [sx, sy, sz] = size;
const slotHeight = Math.min(0.2, sy * 0.25);
const innerX = sx - 2 * WALL;
const innerZ = sz - 2 * WALL;
const bottom = -sy / 2;
const slabs: Slab[] = [
{ size: [WALL, sy, sz], at: [sx / 2 - WALL / 2, 0, 0], radius: EDGE_RADIUS },
{ size: [WALL, sy, sz], at: [-sx / 2 + WALL / 2, 0, 0], radius: EDGE_RADIUS },
{ size: [innerX, sy, WALL], at: [0, 0, -sz / 2 + WALL / 2], radius: EDGE_RADIUS },
// Front wall above the entrance slot.
{ size: [innerX, sy - slotHeight, WALL], at: [0, bottom + slotHeight + (sy - slotHeight) / 2, sz / 2 - WALL / 2], radius: EDGE_RADIUS },
{ size: [innerX, 0.05, innerZ], at: [0, bottom + 0.025, 0], radius: 0.01 },
// Plastic entrance slider in front of the slot (flush with the front face).
{ size: [innerX * 0.86, slotHeight * 0.9, 0.03], at: [0, bottom + slotHeight * 0.5, sz / 2 - 0.015], radius: 0.01, tag: "slider" },
];
// Sticky / varroa board beneath the grid, slid in from the back.
const trayOut = state.varroaTray === "OUT";
const trayDepth = innerZ - 0.06;
const trayZ = trayOut ? -sz * 0.42 : 0;
slabs.push({ size: [innerX - 0.08, 0.035, trayDepth], at: [0, bottom + 0.16, trayZ], radius: 0.008, tag: "tray" });
slabs.push({ size: [innerX - 0.08, 0.1, 0.035], at: [0, bottom + 0.2, trayZ - trayDepth / 2], radius: 0.01, tag: "tray" });
const gridY = sy / 2 - FLOOR_GRID_DEPTH;
const edges = [
...boxEdges(size),
...rectXZ(innerX / 2, innerZ / 2, gridY, 0.6, 0.4),
...rectXZ(innerX / 2, innerZ / 2, sy / 2, 0.4),
];
// Entrance slot across the front, the slider and its opening.
edges.push(...loop(roundedRectPoints(innerX, slotHeight, 0.02, 1), [0, bottom + slotHeight / 2, sz / 2 + 0.004], 0, 1, 0.75, "entrance"));
const opening = ENTRANCE_OPENING[String(state.entrance ?? "OPEN")] ?? ENTRANCE_OPENING.OPEN;
if (opening > 0) {
edges.push(...loop(roundedRectPoints(opening, slotHeight * 0.45, 0.04), [0, bottom + slotHeight * 0.45, sz / 2 + 0.006], 0, 1, 1, "opening"));
}
// Inspection flap on the back wall, between the board slot and the grid, hinged at its top.
const flapHeight = Math.max(0.1, gridY - (bottom + 0.3));
const flapCenter = bottom + 0.27 + flapHeight / 2;
edges.push(...loop(roundedRectPoints(innerX * 0.7, flapHeight, 0.04, 2), [0, flapCenter, -sz / 2 - 0.004], 0, 1, 0.55, "flap"));
edges.push(segment([-innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], [innerX * 0.33, flapCenter + flapHeight / 2, -sz / 2 - 0.006], 0.35, 0, "flap"));
// Board edges.
edges.push(...boxEdges([innerX - 0.08, 0.035, trayDepth], [0, bottom + 0.16, trayZ], 0.35, 0));
return {
slabs,
edges,
grids: [{ size: [innerX, innerZ], at: [0, gridY, 0], cell: 0.07, bars: false }],
frost: 0,
};
}
/** Chambers of a feeder, one per level field, left to right (local coordinates). */
export function feederChambers(size: Vec3, count: number): { center: Vec3; size: Vec3 }[] {
const wall = 0.15;
const chambers = Math.max(1, count);
const innerX = size[0] - 2 * wall;
const innerZ = size[2] - 2 * wall;
const divider = 0.06;
const width = (innerX - (chambers - 1) * divider) / chambers;
const height = size[1] - 0.06;
return Array.from({ length: chambers }, (_, index) => ({
center: [-innerX / 2 + width / 2 + index * (width + divider), -size[1] / 2 + 0.04 + height / 2, 0] as Vec3,
size: [width, height, innerZ] as Vec3,
}));
}
function feederShape(size: Vec3, state: PartState, levelCount: number): PartShapeSpec {
void state;
const [sx, sy, sz] = size;
const wall = 0.15;
const slabs = walls(size, wall);
slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 });
const chambers = feederChambers(size, levelCount);
const edges = [...boxEdges(size), ...innerLines(size, wall)];
for (let index = 1; index < chambers.length; index += 1) {
const x = (chambers[index - 1].center[0] + chambers[index].center[0]) / 2;
slabs.push({ size: [0.06, sy - 0.05, sz - 2 * wall], at: [x, 0.0, 0], radius: 0.01 });
edges.push(segment([x, sy / 2 - 0.03, -sz / 2 + wall], [x, sy / 2 - 0.03, sz / 2 - wall], 0.6, 0.6));
}
return { slabs, edges, grids: [], frost: 0 };
}
function lidShape(size: Vec3, state: PartState): PartShapeSpec {
const [, sy] = size;
const slabs: Slab[] = [{ size, at: [0, 0, 0], radius: 0.03 }];
const edges = [...boxEdges(size)];
edges.push(...loop(circlePoints(0.34), [0, sy / 2 + 0.004, 0], 0, 2, 0.95, "hole"));
edges.push(...loop(circlePoints(0.27), [0, sy / 2 - 0.08, 0], 0, 2, 0.4, "hole"));
if (state.feedHole === "CLOSED") {
slabs.push({ size: [0.84, 0.03, 0.84], at: [0, sy / 2 + 0.015, 0], radius: 0.42, shape: "cylinder", tag: "cap" });
}
return { slabs, edges, grids: [], frost: 0.15 };
}
/** Flat stainless roof with a slight overhang over the part below. */
function roofShape(size: Vec3): PartShapeSpec {
const [sx, sy, sz] = size;
const overhang = 0.13;
const outer: Vec3 = [sx + 2 * overhang, 0.08, sz + 2 * overhang];
const skirt = 0.03;
const skirtHeight = sy - outer[1];
const skirtY = -sy / 2 + skirtHeight / 2;
const slabs: Slab[] = [
{ size: outer, at: [0, sy / 2 - outer[1] / 2, 0], radius: 0.025 },
{ size: [outer[0], skirtHeight, skirt], at: [0, skirtY, outer[2] / 2 - skirt / 2], radius: 0.01 },
{ size: [outer[0], skirtHeight, skirt], at: [0, skirtY, -outer[2] / 2 + skirt / 2], radius: 0.01 },
{ size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [outer[0] / 2 - skirt / 2, skirtY, 0], radius: 0.01 },
{ size: [skirt, skirtHeight, outer[2] - 2 * skirt], at: [-outer[0] / 2 + skirt / 2, skirtY, 0], radius: 0.01 },
];
const edges = [
...boxEdges(outer, [0, sy / 2 - outer[1] / 2, 0], 1.1, 1),
...rectXZ(outer[0] / 2, outer[2] / 2, -sy / 2, 0.7, 0.6),
];
return { slabs, edges, grids: [], frost: 0.05 };
}
function sheetShape(size: Vec3): PartShapeSpec {
const thin: Vec3 = [size[0], Math.max(size[1], 0.025), size[2]];
return { slabs: [{ size: thin, at: [0, 0, 0], radius: 0.006 }], edges: boxEdges(thin, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.1 };
}
/** A flat screen: thin rim bars around a glowing mesh. */
function screenShape(size: Vec3, cell: number, bars: boolean): PartShapeSpec {
const thick: Vec3 = [size[0], Math.max(size[1], 0.04), size[2]];
const rim = 0.12;
const [sx, sy, sz] = thick;
const slabs: Slab[] = [
{ size: [sx, sy, rim], at: [0, 0, sz / 2 - rim / 2], radius: 0.01 },
{ size: [sx, sy, rim], at: [0, 0, -sz / 2 + rim / 2], radius: 0.01 },
{ size: [rim, sy, sz - 2 * rim], at: [sx / 2 - rim / 2, 0, 0], radius: 0.01 },
{ size: [rim, sy, sz - 2 * rim], at: [-sx / 2 + rim / 2, 0, 0], radius: 0.01 },
];
return {
slabs,
edges: [...boxEdges(thick, [0, 0, 0], 0.85, 0.8), ...rectXZ(sx / 2 - rim, sz / 2 - rim, 0, 0.4)],
grids: [{ size: [sx - 2 * rim, sz - 2 * rim], at: [0, 0, 0], cell, bars }],
frost: 0,
};
}
function dividerShape(size: Vec3, state: PartState): PartShapeSpec {
const [sx, , sz] = size;
const slabs = walls(size);
slabs.push({ size: [sx - 2 * WALL, 0.06, sz - 2 * WALL], at: [0, 0, 0], radius: 0.01 });
const edges = [...boxEdges(size), ...innerLines(size)];
for (const x of [-1.1, 1.1]) {
edges.push(...loop(circlePoints(0.22), [x, 0.035, 0], 0, 2, 0.85, "hole"));
if (state.bungHoles === "CLOSED") {
slabs.push({ size: [0.5, 0.04, 0.5], at: [x, 0.05, 0], radius: 0.25, shape: "cylinder", tag: "cap" });
}
}
return { slabs, edges, grids: [], frost: 0.1 };
}
/** Nassenheider evaporator: tray with wick plate and an upright bottle. */
export function evaporatorBottle(size: Vec3): { center: Vec3; radius: number; height: number } {
const radius = Math.min(size[0], size[2]) * 0.24;
const height = size[1] - 0.32;
return { center: [size[0] * 0.18, -size[1] / 2 + 0.2 + height / 2, 0], radius, height };
}
function evaporatorShape(size: Vec3): PartShapeSpec {
const [sx, sy, sz] = size;
const bottle = evaporatorBottle(size);
const trayHeight = 0.16;
const slabs: Slab[] = [
{ size: [sx, trayHeight, sz], at: [0, -sy / 2 + trayHeight / 2, 0], radius: 0.03 },
{ size: [sx - 0.14, 0.03, sz - 0.14], at: [0, -sy / 2 + trayHeight + 0.015, 0], radius: 0.01 },
{
size: [bottle.radius * 2, bottle.height, bottle.radius * 2],
at: bottle.center,
radius: bottle.radius,
shape: "cylinder",
},
{
size: [bottle.radius * 0.9, 0.1, bottle.radius * 0.9],
at: [bottle.center[0], bottle.center[1] + bottle.height / 2 + 0.05, 0],
radius: bottle.radius * 0.45,
shape: "cylinder",
},
];
const top = bottle.center[1] + bottle.height / 2;
const base = bottle.center[1] - bottle.height / 2;
const edges = [
...boxEdges([sx, trayHeight, sz], [0, -sy / 2 + trayHeight / 2, 0], 0.9, 1),
...loop(circlePoints(bottle.radius), [bottle.center[0], top, 0], 0, 2, 0.9),
...loop(circlePoints(bottle.radius), [bottle.center[0], base, 0], 0, 2, 0.6),
];
return { slabs, edges, grids: [], frost: 0 };
}
/** Futtertasche: a narrow glass pocket hanging in the box with a top bar and lugs. */
function pocketShape(size: Vec3): PartShapeSpec {
const [sx, sy, sz] = size;
const wall = 0.045;
const slabs = walls(size, wall, 0.012);
slabs.push({ size: [sx - 2 * wall, 0.04, sz - 2 * wall], at: [0, -sy / 2 + 0.02, 0], radius: 0.01 });
const topBar: Vec3 = [sx + 0.02, 0.08, sz + 0.24];
slabs.push({ size: topBar, at: [0, sy / 2 - 0.04, 0], radius: 0.015 });
return {
slabs,
edges: [...boxEdges(size, [0, 0, 0], 0.9, 1), ...boxEdges(topBar, [0, sy / 2 - 0.04, 0], 0.7, 0.6)],
grids: [],
frost: 0,
};
}
/** Inner liquid space of a frame feeder (local coordinates). */
export function pocketInner(size: Vec3): { center: Vec3; size: Vec3 } {
const wall = 0.045;
const height = size[1] - 0.2;
return { center: [0, -size[1] / 2 + 0.04 + height / 2, 0], size: [size[0] - 2 * wall, height, size[2] - 2 * wall] };
}
export function partShapeSpec(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): PartShapeSpec {
switch (kind) {
case "BOX":
return boxShape(size);
case "FLOOR":
return floorShape(size, state);
case "FEEDER":
return feederShape(size, state, levelCount);
case "INNER_LID":
return lidShape(size, state);
case "ROOF":
return roofShape(size);
case "COVER_SHEET":
return sheetShape(size);
case "PROPOLIS_SCREEN":
return screenShape(size, 0.06, false);
case "QUEEN_EXCLUDER":
return screenShape(size, 0.045, true);
case "FLOOR_INSERT":
return screenShape(size, 0.25, false);
case "INSULATION":
return { slabs: [{ size, at: [0, 0, 0], radius: 0.04 }], edges: boxEdges(size, [0, 0, 0], 0.6, 0.5), grids: [], frost: 0.9 };
case "DIVIDER_BOARD":
return dividerShape(size, state);
case "EVAPORATOR":
return evaporatorShape(size);
case "FRAME_FEEDER":
return pocketShape(size);
default:
return { slabs: [{ size, at: [0, 0, 0], radius: EDGE_RADIUS }], edges: boxEdges(size), grids: [], frost: 0 };
}
}
/** State keys that change a part's shape (everything else is content). */
const SHAPE_STATE_KEYS: Partial<Record<HivePartKind, string[]>> = {
FLOOR: ["varroaTray", "entrance"],
INNER_LID: ["feedHole"],
DIVIDER_BOARD: ["bungHoles"],
};
export function shapeKey(kind: HivePartKind, size: Vec3, state: PartState = {}, levelCount = 0): string {
const keys = SHAPE_STATE_KEYS[kind] ?? [];
const stateKey = keys.map((key) => `${key}=${String(state[key] ?? "")}`).join(",");
return `${kind}|${size.map((value) => value.toFixed(4)).join("x")}|${stateKey}|${kind === "FEEDER" ? levelCount : 0}`;
}
// ---- Frames ----
export interface FrameShapeSpec {
bars: Slab[];
comb: Slab | null;
edges: EdgeSegment[];
}
const COMB_THICKNESS: Partial<Record<FrameType, number>> = { FOUNDATION: 0.03, DIVIDER: 0.12 };
/** Frame in local coordinates: long top bar with lugs, side bars, bottom bar and the comb. */
export function frameShapeSpec(size: Vec3, type: FrameType): FrameShapeSpec {
const [fx, fy, fz] = size;
const topBar: Slab = { size: [fx, 0.1, fz + 0.26], at: [0, fy / 2 - 0.05, 0], radius: 0.015, tag: "topBar" };
const sideHeight = fy - 0.1;
const bars: Slab[] = [
topBar,
{ size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, fz / 2 - 0.045], radius: 0.012 },
{ size: [fx * 0.85, sideHeight, 0.09], at: [0, -0.05, -fz / 2 + 0.045], radius: 0.012 },
{ size: [fx * 0.6, 0.06, fz - 0.18], at: [0, -fy / 2 + 0.03, 0], radius: 0.01 },
];
const comb: Slab | null =
type === "DRONE_FRAME" || type === "NONE"
? null
: {
size: [COMB_THICKNESS[type] ?? 0.17, fy - 0.2, fz - 0.18],
at: [0, -0.04, 0],
radius: 0.01,
tag: "comb",
};
const edges = [
...boxEdges(topBar.size, topBar.at, 0.75, 0.7),
...loop(
[
[fz / 2, fy / 2 - 0.1],
[-fz / 2, fy / 2 - 0.1],
[-fz / 2, -fy / 2],
[fz / 2, -fy / 2],
],
[0, 0, 0],
2,
1,
0.4,
),
];
return { bars, comb, edges };
}
/** Rest-view hint of a frame: only its top bar with lugs, as a faint glass outline. */
export function frameHintSpec(size: Vec3, at: Vec3): { slab: Slab; edges: EdgeSegment[] } {
const slab: Slab = { size: [size[0] * 0.8, 0.08, size[2] + 0.26], at: [at[0], at[1] + size[1] / 2 - 0.04, at[2]], radius: 0.012 };
return { slab, edges: boxEdges(slab.size, slab.at, 0.32, 0.2) };
}
// ---- three.js builders ----
function slabGeometry(slab: Slab): BufferGeometry {
const [w, h, d] = slab.size;
const geometry =
slab.shape === "cylinder"
? new CylinderGeometry(w / 2, w / 2, h, 40, 1)
: new RoundedBoxGeometry(w, h, d, 2, Math.max(0, Math.min(slab.radius, Math.min(w, h, d) / 2 - 1e-4)));
geometry.translate(slab.at[0], slab.at[1], slab.at[2]);
return geometry;
}
/** All slabs of a shape as one geometry (one draw call per part). */
export function buildGlassGeometry(slabs: Slab[]): BufferGeometry {
if (slabs.length === 0) return new BufferGeometry();
const parts = slabs.map((slab) => {
const geometry = slabGeometry(slab);
// mergeGeometries needs the same attributes and index state everywhere.
return geometry.index ? geometry.toNonIndexed() : geometry;
});
const merged = mergeGeometries(parts, false) ?? new BufferGeometry();
parts.forEach((part) => part.dispose());
return merged;
}
/** About 1.5 px at rest-view distance, thicker when a part is in focus. */
const EDGE_WIDTH = 0.024;
/** Length of the white-hot zone at each corner. */
const HOT_ZONE = 0.22;
function perpendicular(direction: Vec3): [Vec3, Vec3] {
const up: Vec3 = Math.abs(direction[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const normalize = (a: Vec3): Vec3 => {
const length = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / length, a[1] / length, a[2] / length];
};
const u = normalize(cross(direction, up));
return [u, normalize(cross(u, direction))];
}
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
const positions: number[] = [];
const hot: number[] = [];
const strength: number[] = [];
const indices: number[] = [];
const half = width / 2;
for (const edge of segments) {
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
const length = Math.hypot(delta[0], delta[1], delta[2]);
if (length < 1e-6) continue;
const direction: Vec3 = [delta[0] / length, delta[1] / length, delta[2] / length];
const [u, w] = perpendicular(direction);
const zone = Math.min(0.35, HOT_ZONE / length);
const rings: [number, number][] = [
[0, edge.hotA],
[zone, 0],
[1 - zone, 0],
[1, edge.hotB],
];
const base = positions.length / 3;
for (const [t, ringHot] of rings) {
for (const [su, sw] of [
[1, 1],
[-1, 1],
[-1, -1],
[1, -1],
]) {
for (let axis = 0; axis < 3; axis += 1) {
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half);
}
hot.push(ringHot);
strength.push(edge.strength);
}
}
for (let band = 0; band < 3; band += 1) {
for (let side = 0; side < 4; side += 1) {
const a = base + band * 4 + side;
const b = base + band * 4 + ((side + 1) % 4);
const c = base + (band + 1) * 4 + ((side + 1) % 4);
const d = base + (band + 1) * 4 + side;
indices.push(a, b, c, a, c, d);
}
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
}
/** One horizontal grid panel as a plane in the xz plane. */
export function buildGridGeometry(panel: GridPanel): BufferGeometry {
const geometry = new PlaneGeometry(panel.size[0], panel.size[1]);
geometry.rotateX(-Math.PI / 2);
geometry.translate(panel.at[0], panel.at[1], panel.at[2]);
return geometry;
}
export interface BuiltShape {
glass: BufferGeometry;
edges: BufferGeometry;
grids: { geometry: BufferGeometry; panel: GridPanel }[];
frost: number;
}
export function buildShape(spec: PartShapeSpec): BuiltShape {
return {
glass: buildGlassGeometry(spec.slabs),
edges: buildEdgeGeometry(spec.edges),
grids: spec.grids.map((panel) => ({ geometry: buildGridGeometry(panel), panel })),
frost: spec.frost,
};
}
export function disposeShape(shape: BuiltShape): void {
shape.glass.dispose();
shape.edges.dispose();
shape.grids.forEach((grid) => grid.geometry.dispose());
}
/** Geometries shared by every node of the same kind and size; disposed with the scene. */
export interface GeometryCache {
shape(key: string, build: () => PartShapeSpec): BuiltShape;
dispose(): void;
}
export function createGeometryCache(): GeometryCache {
const shapes = new Map<string, BuiltShape>();
return {
shape(key, build) {
const known = shapes.get(key);
if (known) return known;
const built = buildShape(build());
shapes.set(key, built);
return built;
},
dispose() {
shapes.forEach(disposeShape);
shapes.clear();
},
};
}
@@ -0,0 +1,52 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Mesh } from "three";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createFrameTextureCache } from "./frame-textures";
import type { FrameTextureCache } from "./frame-textures";
import { createGeometryCache } from "./part-geometry";
import type { GeometryCache } from "./part-geometry";
import { readHoloTheme } from "./theme";
import type { HoloTheme } from "./theme";
// Shared per canvas: theme (tokens read once), geometries per part kind and
// size, comb textures per frame type, the motion level. Everything is
// disposed when the canvas unmounts (spec 9.9: no leak between colonies).
export interface SceneKit {
theme: HoloTheme;
geometries: GeometryCache;
textures: FrameTextureCache;
motion: MotionLevel;
}
const SceneKitContext = createContext<SceneKit | null>(null);
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) {
const [theme] = useState(readHoloTheme);
const [geometries] = useState(createGeometryCache);
const [textures] = useState(() => createFrameTextureCache());
useEffect(
() => () => {
geometries.dispose();
textures.dispose();
},
[geometries, textures],
);
return (
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider>
);
}
export function useSceneKit(): SceneKit {
const kit = useContext(SceneKitContext);
if (!kit) throw new Error("useSceneKit must be used inside a SceneKitProvider");
return kit;
}
/** Raycast that never hits: for visible meshes, so only the hit boxes take pointer events. */
export const noRaycast = (): void => {};
/** The default mesh raycast, for hit boxes that take pointer events. */
export const meshRaycast = Mesh.prototype.raycast;
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import type { MotionView } from "./use-hive-motion";
import { MAX_LABELS, buildSceneModel, formatAmount, levelText, sceneLabels } from "./scene-model";
const catalog = inlineCatalog();
function state(): HiveState {
const boxFrames = frames(10).map((frame) =>
frame.slot <= 2
? { ...frame, type: "NONE" as const }
: frame.slot === 4
? { ...frame, type: "BROOD" as const, name: "Königin" }
: frame,
);
return {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", {
frames: boxFrames,
inserts: [
part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } }),
part("ev", "evaporator", { state: { acidMl: 250 } }),
],
}),
part("b2", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
part("lid", "inner-lid", { state: { feedHole: "CLOSED" } }),
part("r", "roof"),
],
};
}
const hiveState = state();
const layout = hiveLayout(hiveState, catalog);
const name = (id: string) => partDisplayName(hiveState, catalog, id);
const model = buildSceneModel(layout, hiveState, catalog, name);
const rest: MotionView = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
describe("formatAmount", () => {
it("uses the German decimal comma and drops trailing zeros", () => {
expect(formatAmount(2.5, "l")).toBe("2,5 l");
expect(formatAmount(3, "l")).toBe("3 l");
expect(formatAmount(0.25, "l")).toBe("0,25 l");
});
});
describe("buildSceneModel", () => {
it("knows every part and insert with its kind, name and host", () => {
expect(model.get("b2")).toMatchObject({ kind: "BOX", name: "Zarge 2", hostId: null });
expect(model.get("ff")).toMatchObject({ kind: "FRAME_FEEDER", hostId: "b1" });
expect(model.get("fi")).toMatchObject({ kind: "FLOOR_INSERT", hostId: "f" });
});
it("reads level fields with value, max and fill ratio, solid for feed paste", () => {
const feeder = model.get("fe")!;
expect(feeder.levels).toEqual([
expect.objectContaining({ key: "syrupLiters", value: 2.5, max: 3, ratio: 2.5 / 3, solid: false }),
expect.objectContaining({ key: "fondantKg", value: 1, max: 2.5, ratio: 0.4, solid: true }),
]);
expect(levelText(feeder.levels[0])).toBe("Flüssigfutter 2,5 l von 3 l");
expect(levelText(feeder.levels[1])).toBe("Futterteig 1 kg von 2,5 kg");
});
it("treats a missing level as 0 and clamps the ratio", () => {
const empty = buildSceneModel(
layout,
{ ...hiveState, stack: hiveState.stack.map((p) => (p.id === "fe" ? { ...p, state: { syrupLiters: 9 } } : p)) },
catalog,
name,
).get("fe")!;
expect(empty.levels[0].ratio).toBe(1);
expect(empty.levels[1]).toMatchObject({ value: 0, ratio: 0 });
});
it("reads choice fields with their German text, the first option when missing", () => {
expect(model.get("lid")!.choices).toEqual([
{ key: "feedHole", label: "Futterloch", value: "CLOSED", text: "zu" },
]);
});
it("counts the frames of a box that are not empty", () => {
expect(model.get("b1")!.frameCount).toBe(8);
expect(model.get("b2")!.frameCount).toBe(10);
});
});
describe("sceneLabels", () => {
it("shows nothing in the rest view without hover", () => {
expect(sceneLabels(model, layout, rest)).toEqual([]);
});
it("names the hovered part, a box with its frame count", () => {
expect(sceneLabels(model, layout, { ...rest, hoveredId: "b1" })).toEqual([
{ key: "part:b1", anchor: { kind: "node", id: "b1" }, text: "Zarge 1 · 8 Rähmchen", tone: "part" },
]);
expect(sceneLabels(model, layout, { ...rest, hoveredId: "r" })[0].text).toBe("Dach");
});
it("labels the hovered frame „R4 · Brutwabe · Name“ in an open box", () => {
const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } });
expect(labels[0]).toEqual({
key: "frame:b1#4",
anchor: { kind: "frame", boxId: "b1", slot: 4 },
text: "R4 · Brutwabe · Königin",
tone: "frame",
});
});
it("keeps the label of a selected frame", () => {
const labels = sceneLabels(model, layout, { ...rest, openBoxId: "b2", selectedFrame: { boxId: "b2", slot: 7 } });
expect(labels[0].text).toBe("R7 · Mittelwand");
});
it("shows the levels of a focused feeder", () => {
const labels = sceneLabels(model, layout, { ...rest, focusedPartId: "fe" });
expect(labels.map((label) => label.text)).toEqual(["Flüssigfutter 2,5 l von 3 l", "Futterteig 1 kg von 2,5 kg"]);
expect(labels[0]).toMatchObject({ anchor: { kind: "node", id: "fe" }, tone: "level" });
});
it("shows the levels of the inserts of an open box and never more than three labels", () => {
const labels = sceneLabels(model, layout, {
...rest,
openBoxId: "b1",
hoveredId: "ev",
hoveredFrame: { boxId: "b1", slot: 4 },
});
expect(labels.length).toBeLessThanOrEqual(MAX_LABELS);
expect(MAX_LABELS).toBe(3);
expect(labels[0].tone).toBe("frame");
expect(labels.some((label) => label.text === "Flüssigfutter 1,5 l von 2 l")).toBe(true);
});
it("does not label ghost parts hovered from the outline in focus mode", () => {
expect(sceneLabels(model, layout, { ...rest, focusedPartId: "lid", hoveredId: "b2" })).toEqual([]);
});
});
@@ -0,0 +1,190 @@
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
import type { HiveLayout, PartBox } from "@/lib/hive/layout";
import type { CatalogPartType, HiveCatalog, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
import { focusedIdOf } from "../use-hive-view";
import type { MotionView } from "./use-hive-motion";
// What the scene shows about each part besides its shape: name, levels,
// choices and frame count. Pure, so the labels can be tested without WebGL.
/** Spec 9.3: never more than three labels at once. */
export const MAX_LABELS = 3;
export interface SceneLevel {
key: string;
label: string;
unit: string;
value: number;
max: number;
/** value / max, clamped to 0…1. */
ratio: number;
/** Feed paste is a block, everything else a liquid. */
solid: boolean;
}
export interface SceneChoice {
key: string;
label: string;
value: string;
text: string;
}
export interface ScenePart {
id: string;
kind: HivePartKind;
type: CatalogPartType;
box: PartBox;
hostId: string | null;
name: string;
levels: SceneLevel[];
choices: SceneChoice[];
/** Frames that are not empty slots (boxes only). */
frameCount: number;
state: HivePart["state"];
}
export type SceneModel = Map<string, ScenePart>;
const amountFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 });
export function formatAmount(value: number, unit: string): string {
return `${amountFormat.format(value)} ${unit}`;
}
export function levelText(level: SceneLevel): string {
return `${level.label} ${formatAmount(level.value, level.unit)} von ${formatAmount(level.max, level.unit)}`;
}
function readLevels(part: HivePart, type: CatalogPartType): SceneLevel[] {
return type.stateSchema.flatMap((field) => {
if (field.kind !== "level") return [];
const raw = part.state[field.key];
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
const ratio = field.max > 0 ? Math.min(1, Math.max(0, value / field.max)) : 0;
return [
{
key: field.key,
label: field.label,
unit: field.unit,
value,
max: field.max,
ratio,
solid: field.feedType === "FEED_PASTE" || field.unit === "kg",
},
];
});
}
function readChoices(part: HivePart, type: CatalogPartType): SceneChoice[] {
return type.stateSchema.flatMap((field) => {
if (field.kind !== "choice" || field.options.length === 0) return [];
const raw = part.state[field.key];
const index = Math.max(0, field.options.indexOf(String(raw)));
return [
{
key: field.key,
label: field.label,
value: field.options[index],
text: field.optionLabels[index] ?? field.options[index],
},
];
});
}
export function buildSceneModel(
layout: HiveLayout,
state: HiveState,
catalog: HiveCatalog,
partName: (id: string) => string,
): SceneModel {
const boxes = new Map<string, PartBox>();
for (const placed of [...layout.parts, ...layout.inserts]) boxes.set(placed.id, placed);
const model: SceneModel = new Map();
const add = (part: HivePart, hostId: string | null): void => {
const type = catalog.partTypes.get(part.partTypeId);
const box = boxes.get(part.id);
if (!type || !box) return;
model.set(part.id, {
id: part.id,
kind: type.kind,
type,
box,
hostId,
name: partName(part.id),
levels: readLevels(part, type),
choices: readChoices(part, type),
frameCount: (part.frames ?? []).filter((frame) => frame.type !== "NONE").length,
state: part.state,
});
};
for (const part of state.stack) {
add(part, null);
for (const insert of part.inserts ?? []) add(insert, part.id);
}
return model;
}
export type LabelAnchor = { kind: "node"; id: string } | { kind: "frame"; boxId: string; slot: number };
export interface SceneLabel {
key: string;
anchor: LabelAnchor;
text: string;
tone: "part" | "frame" | "level";
}
function frameLabel(layout: HiveLayout, view: MotionView): SceneLabel | null {
if (!view.openBoxId) return null;
const target = view.hoveredFrame ?? view.selectedFrame;
if (!target || target.boxId !== view.openBoxId) return null;
const frame = layout.frames.find((candidate) => candidate.boxId === target.boxId && candidate.slot === target.slot);
if (!frame || frame.type === "NONE") return null;
const parts = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type]];
if (frame.name) parts.push(frame.name);
return {
key: `frame:${frame.boxId}#${frame.slot}`,
anchor: { kind: "frame", boxId: frame.boxId, slot: frame.slot },
text: parts.join(" · "),
tone: "frame",
};
}
function hoverLabel(model: SceneModel, view: MotionView): SceneLabel | null {
const id = view.hoveredId;
if (!id) return null;
const part = model.get(id);
if (!part) return null;
const focused = focusedIdOf(view);
// In focus mode only the open box's inserts can be hovered; ghosts stay unlabelled.
if (focused !== null && (view.openBoxId === null || part.hostId !== view.openBoxId)) return null;
if (focused === null && part.hostId !== null) return null;
const text = part.kind === "BOX" && part.frameCount > 0 ? `${part.name} · ${part.frameCount} Rähmchen` : part.name;
return { key: `part:${id}`, anchor: { kind: "node", id }, text, tone: "part" };
}
function levelLabels(model: SceneModel, view: MotionView): SceneLabel[] {
const focused = focusedIdOf(view);
if (!focused) return [];
const inserts = view.openBoxId
? [...model.values()].filter((part) => part.hostId === view.openBoxId).map((part) => part.id)
: [];
const shown = [focused, ...inserts];
return shown.flatMap((id) => {
const part = model.get(id);
if (!part) return [];
return part.levels.map((level) => ({
key: `level:${id}:${level.key}`,
anchor: { kind: "node", id } as LabelAnchor,
text: levelText(level),
tone: "level" as const,
}));
});
}
/** Labels to show for the view state: frame first, then hover, then levels; at most three. */
export function sceneLabels(model: SceneModel, layout: HiveLayout, view: MotionView): SceneLabel[] {
const labels = [frameLabel(layout, view), hoverLabel(model, view), ...levelLabels(model, view)].filter(
(label): label is SceneLabel => label !== null,
);
return labels.slice(0, MAX_LABELS);
}
@@ -0,0 +1,54 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit";
// Imperative three.js updates used from frame callbacks and effects. They live
// outside the components on purpose: scene objects are mutable by design,
// while React values (props, memoised objects) must not be mutated in render
// code (react-hooks/immutability).
export function setUniform(material: ShaderMaterial, name: string, value: number): void {
const uniform = material.uniforms[name];
if (uniform) uniform.value = value;
}
export function setUniformColor(material: ShaderMaterial, name: string, color: Color): void {
const uniform = material.uniforms[name];
if (uniform) (uniform.value as Color).copy(color);
}
export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity;
material.visible = opacity > 0.01;
}
/** Puts the camera on the fixed viewing direction at `distance` from `center`. */
export function placeCamera(camera: Camera, center: Vec3, distance: number): void {
const [x, y, z] = cameraPosition(center, distance);
camera.position.set(x, y, z);
camera.lookAt(center[0], center[1], center[2]);
}
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
scene.background = background;
}
export function setCanvasCursor(gl: WebGLRenderer, pointer: boolean): void {
const style = (gl.domElement as HTMLElement | undefined)?.style;
if (style) style.cursor = pointer ? "pointer" : "";
}
/** Scales a unit fill mesh to `ratio` of the space (centre, size) and sets its surface wave. */
export function placeFill(
mesh: Mesh,
center: Vec3,
size: Vec3,
ratio: number,
cylinder: boolean,
): number {
const height = Math.max(size[1] * ratio, 1e-4);
mesh.visible = ratio > 0.002;
mesh.scale.set(size[0], height, cylinder ? size[0] : size[2]);
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
return height;
}
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { useThree } from "@react-three/fiber";
import type { ThreeEvent } from "@react-three/fiber";
import { BackSide, FrontSide } from "three";
import type { Group } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { FillVolume } from "./fill-volume";
import { FrameHints } from "./frame-plate";
import { HoloLabel } from "./hive-labels";
import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials";
import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setUniform, setUniformColor } from "./scene-mutations";
import type { SceneLabel, ScenePart } from "./scene-model";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import type { NodeTarget } from "./use-hive-motion";
// One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its
// label. Inserts and frames are children, so they fly with their host.
export interface NodeHandlers {
onPointerOver(event: ThreeEvent<PointerEvent>): void;
onPointerOut(event: ThreeEvent<PointerEvent>): void;
onPointerDown(event: ThreeEvent<PointerEvent>): void;
onClick(event: ThreeEvent<MouseEvent>): void;
}
/** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */
const EDGE_INTENSITY = 1.25;
export interface StackPartProps {
part: ScenePart;
/** Position relative to the parent: world for stack parts, the host centre for inserts. */
target: NodeTarget;
/** This part is the one in focus mode. */
focused: boolean;
handlers: NodeHandlers;
/** Labels anchored at this part (hover name, levels). */
labels: SceneLabel[];
/** Frames of a box, drawn as faint top-bar hints at rest. */
hintFrames?: FrameBox[];
children?: ReactNode;
}
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion } = useSceneKit();
const group = useRef<Group>(null);
const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length),
);
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
[theme, shape.grids],
);
useEffect(
() => () => {
glass.dispose();
edges.dispose();
grids.forEach((material) => material.dispose());
},
[glass, edges, grids],
);
const apply = ({ opacity, glow }: AnimatedValues) => {
setUniform(glass, "uOpacity", opacity);
setUniform(glass, "uGlow", glow);
// Ghost edges keep a little more presence than the faces, so the outline of the hive reads.
setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6));
setUniform(edges, "uIntensity", EDGE_INTENSITY * glow);
setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge);
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
};
useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
const showHints = hintFrames && hintFrames.length > 0 && !target.content;
const sy = box.size[1];
return (
<group ref={group}>
<mesh geometry={shape.glass} material={glass} raycast={noRaycast} renderOrder={1} dispose={null} />
{shape.grids.map((grid, index) => (
<mesh
key={index}
geometry={grid.geometry}
material={grids[index]}
raycast={noRaycast}
renderOrder={2}
dispose={null}
/>
))}
<mesh geometry={shape.edges} material={edges} raycast={noRaycast} renderOrder={4} dispose={null} />
{showHints ? <FrameHints frames={hintFrames} hostCenter={box.center} glass={glass} edges={edges} /> : null}
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
<mesh
position={[0, -target.lift / 2, 0]}
raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut}
onPointerDown={handlers.onPointerDown}
onClick={handlers.onClick}
userData={{ hit: part.id }}
>
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} />
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
</mesh>
{target.content ? <PartContent part={part} labels={labels} /> : null}
{labels
.filter((label) => label.tone !== "level")
.map((label) => (
<HoloLabel key={label.key} position={[0, sy / 2 + 0.06, 0]} text={label.text} tone="frame" />
))}
{children}
</group>
);
}
/** Below this canvas width level labels would run off the card; the focus chips carry the same text. */
const LEVEL_LABEL_MIN_WIDTH = 520;
function levelLabel(labels: SceneLabel[], partId: string, key: string): SceneLabel | undefined {
return labels.find((label) => label.key === `level:${partId}:${key}`);
}
/** What a focused part shows inside (spec 9.3): feed, acid, pocket liquid. */
function PartContent({ part, labels: allLabels }: { part: ScenePart; labels: SceneLabel[] }) {
const { kind, box, levels } = part;
const wide = useThree((state) => state.size.width >= LEVEL_LABEL_MIN_WIDTH);
const labels = wide ? allLabels : [];
if (kind === "FEEDER") {
const chambers = feederChambers(box.size, levels.length);
return (
<>
{levels.map((level, index) => {
const chamber = chambers[index];
const label = levelLabel(labels, part.id, level.key);
const top: Vec3 = [chamber.center[0], chamber.center[1] + chamber.size[1] / 2, chamber.size[2] / 2];
return (
<group key={level.key}>
{level.solid ? (
<FillVolume
center={chamber.center}
size={[chamber.size[0] * 0.7, chamber.size[1] * 0.9, chamber.size[2] * 0.55]}
ratio={level.ratio}
kind="fondant"
/>
) : (
<FillVolume center={chamber.center} size={chamber.size} ratio={level.ratio} kind="honey" />
)}
{label ? <HoloLabel position={top} text={label.text} tone="level" /> : null}
</group>
);
})}
</>
);
}
if (kind === "FRAME_FEEDER" && levels[0]) {
const inner = pocketInner(box.size);
const label = levelLabel(labels, part.id, levels[0].key);
return (
<>
<FillVolume center={inner.center} size={inner.size} ratio={levels[0].ratio} kind="honey" />
{label ? <HoloLabel position={[box.size[0] / 2, inner.center[1], 0]} text={label.text} tone="level" /> : null}
</>
);
}
if (kind === "EVAPORATOR" && levels[0]) {
const bottle = evaporatorBottle(box.size);
const inner: Vec3 = [bottle.radius * 1.8, bottle.height - 0.06, bottle.radius * 1.8];
const label = levelLabel(labels, part.id, levels[0].key);
return (
<>
<FillVolume center={bottle.center} size={inner} ratio={levels[0].ratio} kind="acid" shape="cylinder" />
{label ? (
<HoloLabel
position={[bottle.center[0] + bottle.radius, bottle.center[1], 0]}
text={label.text}
tone="level"
/>
) : null}
</>
);
}
return null;
}
@@ -0,0 +1,72 @@
import { Color } from "three";
// Scene colours. The holo tokens (spec 9.3) are read once from :root; the
// material colours (honey, wax, white-hot corners) are fixed design values.
// Lighting stays cool blue/cyan and white; warm colours only appear as
// content (feed in focus, lifted frame looks).
export interface HoloTheme {
background: Color;
/** Electric cyan along the edges. */
edge: Color;
/** Brighter edge of a hovered or focused part. */
edgeStrong: Color;
/** White-hot corners. */
hot: Color;
/** Glass body tint. */
tint: Color;
/** Cool rim light from behind. */
rim: Color;
/** Faint cool light bouncing up from the floor (owner: no warm light in the rest view). */
bounce: Color;
honey: Color;
honeyDeep: Color;
fondant: Color;
acid: Color;
/** Hex lines on the floor. */
grid: Color;
}
const FALLBACKS = {
"--holo-bg": "#0b1224",
"--holo-edge": "#33c2f0",
"--holo-edge-strong": "#8fe3ff",
} as const;
type TokenName = keyof typeof FALLBACKS;
/**
* Resolves a CSS colour token (the holo tokens are oklch) to a hex string
* three.js understands, by letting a 2D canvas convert it. Falls back when
* there is no 2D context (jsdom) or the token is missing.
*/
export function resolveCssColor(property: TokenName): string {
const fallback = FALLBACKS[property];
if (typeof document === "undefined") return fallback;
const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim();
if (!value) return fallback;
const context = document.createElement("canvas").getContext("2d");
if (!context) return fallback;
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
}
/** Reads the tokens; call once per canvas (useState initialiser). */
export function readHoloTheme(): HoloTheme {
return {
background: new Color(resolveCssColor("--holo-bg")),
edge: new Color(resolveCssColor("--holo-edge")),
edgeStrong: new Color(resolveCssColor("--holo-edge-strong")),
hot: new Color("#f2fbff"),
tint: new Color("#7fb8e8"),
rim: new Color("#4aa8ff"),
bounce: new Color("#9fd8ff"),
honey: new Color("#ffb21a"),
honeyDeep: new Color("#8a3a00"),
fondant: new Color("#f1d79a"),
acid: new Color("#c8f4ff"),
grid: new Color("#2a6fd6"),
};
}
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` damps position, scale and
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving.
/** After a pause the demand loop reports a long delta; never step more than this. */
export const MAX_FRAME_SECONDS = 1 / 30;
export interface AnimatedTarget {
position: Vec3;
scale: number;
opacity: number;
glow: number;
/** 0 = clear glass, 1 = type look (frames only). */
look?: number;
}
export interface AnimatedValues {
opacity: number;
glow: number;
look: number;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number {
if (motion === "full") return stepToward(current, target, dt, true);
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
return target;
}
export function useAnimatedNode(
ref: RefObject<Object3D | null>,
target: AnimatedTarget,
motion: MotionLevel,
apply: (values: AnimatedValues) => void,
): void {
const invalidate = useThree((state) => state.invalidate);
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null);
const latestApply = useRef(apply);
useEffect(() => {
latestApply.current = apply;
});
// A new target starts the frame loop.
const [px, py, pz] = target.position;
useEffect(() => {
invalidate();
}, [px, py, pz, target.scale, target.opacity, target.glow, target.look, invalidate]);
useFrame((_, delta) => {
const node = ref.current;
if (!node) return;
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
if (!current.current) {
// First frame: appear in place, no fly-in from the origin.
current.current = { position: [...target.position], scale: target.scale, values: goal };
} else {
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const damped = motion === "full";
const state = current.current;
state.position = [
stepToward(state.position[0], target.position[0], dt, damped),
stepToward(state.position[1], target.position[1], dt, damped),
stepToward(state.position[2], target.position[2], dt, damped),
];
state.scale = stepToward(state.scale, target.scale, dt, damped);
state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion),
glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion),
};
}
const state = current.current;
node.position.set(state.position[0], state.position[1], state.position[2]);
node.scale.setScalar(state.scale);
latestApply.current(state.values);
const settled =
state.position.every((value, axis) => value === target.position[axis]) &&
state.scale === target.scale &&
state.values.opacity === goal.opacity &&
state.values.glow === goal.glow &&
state.values.look === goal.look;
if (!settled) invalidate();
});
}
@@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "@/lib/hive/layout";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import type { HiveViewState } from "../use-hive-view";
import {
FOCUS_SCALE,
FRAME_LIFT_RATIO,
GHOST_OPACITY,
HOVER_LIFT,
focusPlacement,
frameKey,
hiveMotionTargets,
REST_GAP,
restBounds,
restCenter,
shouldIdle,
stepToward,
} from "./use-hive-motion";
const catalog = inlineCatalog();
function sampleState(): HiveState {
const boxFrames = frames(10).map((frame) =>
frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame.slot === 4 ? { ...frame, type: "BROOD" as const } : frame,
);
return {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b1", "box", {
frames: boxFrames,
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
}),
part("b2", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 1 } }),
part("r", "roof"),
],
};
}
const layout = hiveLayout(sampleState(), catalog);
const rest: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
const box = (id: string) => layout.parts.find((candidate) => candidate.id === id)!;
describe("hiveMotionTargets", () => {
it("keeps every part at its layout place, full opacity and interactive in the rest view", () => {
const targets = hiveMotionTargets(layout, rest);
for (const placed of layout.parts) {
const node = targets.nodes.get(placed.id)!;
expect(node.position).toEqual(restCenter(placed));
expect(node.scale).toBe(1);
expect(node.opacity).toBe(1);
expect(node.interactive).toBe(true);
expect(node.content).toBe(false);
}
expect(targets.focus).toBeNull();
});
it("places inserts relative to their host and keeps them passive in the rest view", () => {
const targets = hiveMotionTargets(layout, rest);
const insert = layout.inserts.find((candidate) => candidate.id === "fi")!;
const node = targets.nodes.get("fi")!;
expect(node.hostId).toBe("f");
expect(node.position[1]).toBeCloseTo(insert.center[1] - box("f").center[1], 9);
expect(node.interactive).toBe(false);
});
it("shows frames only as hints at rest and hides empty slots", () => {
const targets = hiveMotionTargets(layout, rest);
expect(targets.frames.get(frameKey("b1", 4))!.look).toBe("hint");
expect(targets.frames.get(frameKey("b1", 1))!.look).toBe("hidden");
expect(targets.frames.get(frameKey("b1", 4))!.interactive).toBe(false);
});
it("lifts the hovered part by 8 mm and makes its edges brighter, others stay", () => {
const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" });
const hovered = targets.nodes.get("b2")!;
expect(hovered.position[1]).toBeCloseTo(restCenter(box("b2"))[1] + HOVER_LIFT, 9);
expect(HOVER_LIFT).toBeCloseTo(0.08, 9);
expect(hovered.glow).toBeGreaterThan(targets.nodes.get("b1")!.glow);
expect(targets.nodes.get("b1")!.position).toEqual(restCenter(box("b1")));
});
it("reports the lift, so the hit box can stay under the pointer", () => {
const targets = hiveMotionTargets(layout, { ...rest, hoveredId: "b2" });
expect(targets.nodes.get("b2")!.lift).toBeCloseTo(HOVER_LIFT, 9);
expect(targets.nodes.get("b1")!.lift).toBe(0);
});
it("opens the rest stack by a small gap per level and reports the bounds with the gaps", () => {
expect(restCenter(box("b2"))[1]).toBeCloseTo(box("b2").center[1] + 2 * REST_GAP, 9);
const bounds = restBounds(layout);
expect(bounds.size[1]).toBeCloseTo(layout.bounds.h + (layout.parts.length - 1) * REST_GAP, 9);
expect(bounds.center[1]).toBeCloseTo(bounds.size[1] / 2, 9);
});
it("flies an open box to the focus place, scales it and ghosts every other part at ≈ 0.08", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" });
const focused = targets.nodes.get("b2")!;
expect(focused.position).toEqual(focusPlacement(layout.bounds, box("b2").size));
expect(focused.scale).toBe(FOCUS_SCALE);
expect(focused.opacity).toBe(1);
expect(focused.content).toBe(true);
for (const [id, node] of targets.nodes) {
if (id === "b2") continue;
expect(node.opacity).toBeCloseTo(GHOST_OPACITY, 9);
expect(node.interactive).toBe(false);
}
expect(GHOST_OPACITY).toBeCloseTo(0.08, 9);
expect(targets.focus).toMatchObject({ id: "b2", kind: "BOX" });
});
it("keeps the focus place clear of the ghost hive", () => {
const size = box("b2").size;
const [, , z] = focusPlacement(layout.bounds, size);
expect(z - (size[2] * FOCUS_SCALE) / 2).toBeGreaterThan(layout.bounds.d / 2);
});
it("shows the frames of the open box as clear glass plates, ghosts the others", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b2" });
const plate = targets.frames.get(frameKey("b2", 3))!;
expect(plate.look).toBe("glass");
expect(plate.interactive).toBe(true);
expect(plate.opacity).toBe(1);
const other = targets.frames.get(frameKey("b1", 4))!;
expect(other.look).toBe("hint");
expect(other.opacity).toBeCloseTo(GHOST_OPACITY, 9);
});
it("lifts the hovered frame by 40 % of its height and gives it its type look", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredFrame: { boxId: "b1", slot: 4 } });
const frame = layout.frames.find((candidate) => candidate.boxId === "b1" && candidate.slot === 4)!;
const lifted = targets.frames.get(frameKey("b1", 4))!;
expect(lifted.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * frame.size[1], 9);
expect(lifted.look).toBe("type");
const neighbour = targets.frames.get(frameKey("b1", 5))!;
expect(neighbour.look).toBe("glass");
expect(neighbour.position[1]).toBeCloseTo(frame.center[1] - box("b1").center[1], 9);
});
it("keeps a selected frame lifted with its type look after the pointer left", () => {
const targets = hiveMotionTargets(layout, {
...rest,
openBoxId: "b1",
selectedFrame: { boxId: "b1", slot: 5 },
});
expect(targets.frames.get(frameKey("b1", 5))!.look).toBe("type");
expect(targets.frames.get(frameKey("b1", 5))!.position[1]).toBeGreaterThan(0);
});
it("makes the inserts of the open box interactive and shows their content", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1" });
const pocket = targets.nodes.get("ff")!;
expect(pocket.interactive).toBe(true);
expect(pocket.content).toBe(true);
expect(pocket.opacity).toBe(1);
});
it("lifts a hovered frame feeder like a frame", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "b1", hoveredId: "ff" });
const insert = layout.inserts.find((candidate) => candidate.id === "ff")!;
expect(targets.nodes.get("ff")!.position[1]).toBeCloseTo(
insert.center[1] - box("b1").center[1] + FRAME_LIFT_RATIO * insert.size[1],
9,
);
expect(targets.nodes.get("ff")!.lift).toBeCloseTo(FRAME_LIFT_RATIO * insert.size[1], 9);
});
it("flies a focused insert out on its own and ghosts its host", () => {
const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fi" });
const insert = layout.inserts.find((candidate) => candidate.id === "fi")!;
const place = focusPlacement(layout.bounds, insert.size);
const node = targets.nodes.get("fi")!;
const host = restCenter(box("f"));
// Inserts are positioned relative to the host, which stays at its rest place.
expect(node.position[0] + host[0]).toBeCloseTo(place[0], 9);
expect(node.position[1] + host[1]).toBeCloseTo(place[1], 9);
expect(node.position[2] + host[2]).toBeCloseTo(place[2], 9);
expect(node.opacity).toBe(1);
expect(node.content).toBe(true);
expect(targets.nodes.get("f")!.opacity).toBeCloseTo(GHOST_OPACITY, 9);
expect(targets.nodes.get("f")!.position).toEqual(host);
});
it("focuses a feeder and shows its content", () => {
const targets = hiveMotionTargets(layout, { ...rest, focusedPartId: "fe" });
const feeder = targets.nodes.get("fe")!;
expect(feeder.content).toBe(true);
expect(feeder.interactive).toBe(true);
expect(targets.focus).toMatchObject({ id: "fe", kind: "FEEDER" });
expect(targets.nodes.get("b1")!.opacity).toBeCloseTo(GHOST_OPACITY, 9);
});
it("ignores a focus id that is not in the layout", () => {
const targets = hiveMotionTargets(layout, { ...rest, openBoxId: "gone" });
expect(targets.focus).toBeNull();
expect(targets.nodes.get("b1")!.opacity).toBe(1);
});
});
describe("stepToward", () => {
it("jumps at once when not damped", () => {
expect(stepToward(0, 1, 0.016, false)).toBe(1);
});
it("moves part of the way per frame and settles within about 350 ms", () => {
let value = 0;
const first = stepToward(value, 1, 1 / 60, true);
expect(first).toBeGreaterThan(0);
expect(first).toBeLessThan(1);
for (let frame = 0; frame < 21; frame += 1) value = stepToward(value, 1, 1 / 60, true);
expect(value).toBeGreaterThan(0.98);
});
});
describe("shouldIdle", () => {
it("runs only at full, while the card and the tab are visible", () => {
expect(shouldIdle({ motion: "full", visible: true, tabVisible: true })).toBe(true);
expect(shouldIdle({ motion: "subtle", visible: true, tabVisible: true })).toBe(false);
expect(shouldIdle({ motion: "off", visible: true, tabVisible: true })).toBe(false);
expect(shouldIdle({ motion: "full", visible: false, tabVisible: true })).toBe(false);
expect(shouldIdle({ motion: "full", visible: true, tabVisible: false })).toBe(false);
});
});
@@ -0,0 +1,305 @@
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HivePartKind } from "@/lib/hive/types";
import type { HiveViewState, HoveredFrame } from "../use-hive-view";
import { focusedIdOf } from "../use-hive-view";
// Pure targets for every node of the scene (spec 9.4). The scene animates
// toward them at motion level `full` and sets them directly otherwise.
// Units are layout units (1 = 100 mm, y up).
/** A hovered part rises 8 mm. */
export const HOVER_LIFT = 0.08;
/** Every part outside the focus stays as a faint ghost of the whole hive. */
export const GHOST_OPACITY = 0.08;
/** The focused part flies out and grows by this factor. */
export const FOCUS_SCALE = 1.25;
/** A hovered or selected frame rises by 40 % of its height. */
export const FRAME_LIFT_RATIO = 0.4;
/** The rest stack is slightly exploded (mockup g2): this gap per stack level. */
export const REST_GAP = 0.14;
/** Clear space between the ghost hive and the focused part. */
const FOCUS_GAP = 0.45;
/** The focused part floats a little above the floor. */
const FOCUS_FLOAT = 0.18;
/** Share of the hive width the focus place moves to the left (camera at 30° azimuth). */
const FOCUS_SHIFT = 0.35;
export const GLOW = { rest: 1, hover: 2.3, focus: 1.5, ghost: 0.45 } as const;
export type FrameLook = "hidden" | "hint" | "glass" | "type";
export interface NodeTarget {
/** Stack parts: world position. Inserts: relative to the host's centre. */
position: Vec3;
scale: number;
opacity: number;
/** Edge brightness multiplier. */
glow: number;
/** Takes pointer events (ghosts and parts inside a closed host do not). */
interactive: boolean;
/** Shows its content (levels, frame looks) - only in focus mode. */
content: boolean;
hostId: string | null;
/** How far the node is raised above its place (hover); its hit box reaches back down. */
lift: number;
}
export interface FrameTarget {
/** Relative to the box centre. */
position: Vec3;
opacity: number;
look: FrameLook;
interactive: boolean;
}
export interface FocusInfo {
id: string;
kind: HivePartKind;
/** World centre of the focused part at its focus place. */
center: Vec3;
/** Scaled size, with head room for a lifted frame. */
size: Vec3;
}
export interface HiveMotionTargets {
nodes: Map<string, NodeTarget>;
frames: Map<string, FrameTarget>;
focus: FocusInfo | null;
}
export interface MotionView extends HiveViewState {
/** Frame whose sheet was opened from the scene: stays lifted until focus changes. */
selectedFrame?: HoveredFrame | null;
}
export const frameKey = (boxId: string, slot: number): string => `${boxId}#${slot}`;
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
/** Rest place of a stack part: its layout centre raised by the stack gap. */
export function restCenter(placed: PartBox): Vec3 {
return lift(placed.center, placed.stackIndex * REST_GAP);
}
/** World box of the rest stack (centre and size), gaps included. */
export function restBounds(layout: HiveLayout): { center: Vec3; size: Vec3 } {
const levels = layout.parts.length;
const height = layout.bounds.h + Math.max(0, levels - 1) * REST_GAP;
return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] };
}
/**
* World centre a focused part flies to: in front of the hive and to the left,
* so the ghost hive stays visible behind it on the right (camera azimuth 30°).
*/
export function focusPlacement(bounds: HiveLayout["bounds"], size: Vec3): Vec3 {
return [
-bounds.w * FOCUS_SHIFT,
(size[1] * FOCUS_SCALE) / 2 + FOCUS_FLOAT,
bounds.d / 2 + (size[2] * FOCUS_SCALE) / 2 + FOCUS_GAP,
];
}
function hostOf(layout: HiveLayout, insert: PartBox): PartBox | undefined {
return layout.parts.find((candidate) => candidate.stackIndex === insert.stackIndex);
}
const sameFrame = (frame: FrameBox, other: HoveredFrame | null | undefined): boolean =>
other != null && other.boxId === frame.boxId && other.slot === frame.slot;
interface FocusState {
id: string;
box: PartBox;
isInsert: boolean;
hostId: string | null;
}
function findFocus(layout: HiveLayout, view: MotionView): FocusState | null {
const id = focusedIdOf(view);
if (!id) return null;
const stackPart = layout.parts.find((candidate) => candidate.id === id);
if (stackPart) return { id, box: stackPart, isInsert: false, hostId: null };
const insert = layout.inserts.find((candidate) => candidate.id === id);
if (!insert) return null;
return { id, box: insert, isInsert: true, hostId: hostOf(layout, insert)?.id ?? null };
}
function focusInfo(layout: HiveLayout, focus: FocusState): FocusInfo {
const { box } = focus;
const center = focusPlacement(layout.bounds, box.size);
const size: Vec3 = [box.size[0] * FOCUS_SCALE, box.size[1] * FOCUS_SCALE, box.size[2] * FOCUS_SCALE];
if (box.kind !== "BOX") return { id: focus.id, kind: box.kind, center, size };
// Leave room above the box for a frame lifted by 40 % of its height.
const frameHeight = layout.frames.find((frame) => frame.boxId === box.id)?.size[1] ?? 0;
const headRoom = FRAME_LIFT_RATIO * frameHeight * FOCUS_SCALE;
return {
id: focus.id,
kind: box.kind,
center: lift(center, headRoom / 2),
size: [size[0], size[1] + headRoom, size[2]],
};
}
function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | null): Map<string, NodeTarget> {
const nodes = new Map<string, NodeTarget>();
for (const placed of layout.parts) {
const focused = focus !== null && !focus.isInsert && focus.id === placed.id;
if (focused) {
nodes.set(placed.id, {
position: focusPlacement(layout.bounds, placed.size),
scale: FOCUS_SCALE,
opacity: 1,
glow: GLOW.focus,
interactive: true,
content: true,
hostId: null,
lift: 0,
});
continue;
}
const home = restCenter(placed);
if (focus) {
nodes.set(placed.id, {
position: home,
scale: 1,
opacity: GHOST_OPACITY,
glow: GLOW.ghost,
interactive: false,
content: false,
hostId: null,
lift: 0,
});
continue;
}
const hovered = view.hoveredId === placed.id;
nodes.set(placed.id, {
position: hovered ? lift(home, HOVER_LIFT) : home,
scale: 1,
opacity: 1,
glow: hovered ? GLOW.hover : GLOW.rest,
interactive: true,
content: false,
hostId: null,
lift: hovered ? HOVER_LIFT : 0,
});
}
return nodes;
}
function insertTarget(
layout: HiveLayout,
view: MotionView,
focus: FocusState | null,
insert: PartBox,
host: PartBox,
): NodeTarget {
const local = sub(insert.center, host.center);
if (focus?.isInsert && focus.id === insert.id) {
return {
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host)),
scale: FOCUS_SCALE,
opacity: 1,
glow: GLOW.focus,
interactive: true,
content: true,
hostId: host.id,
lift: 0,
};
}
const hostFocused = focus !== null && !focus.isInsert && focus.id === host.id;
if (hostFocused) {
const hovered = view.hoveredId === insert.id;
const rise = insert.kind === "FRAME_FEEDER" ? FRAME_LIFT_RATIO * insert.size[1] : HOVER_LIFT;
return {
position: hovered ? lift(local, rise) : local,
scale: 1,
opacity: 1,
glow: hovered ? GLOW.hover : GLOW.rest,
interactive: true,
content: true,
hostId: host.id,
lift: hovered ? rise : 0,
};
}
return {
position: local,
scale: 1,
opacity: focus ? GHOST_OPACITY : 1,
glow: focus ? GLOW.ghost : GLOW.rest,
interactive: false,
content: false,
hostId: host.id,
lift: 0,
};
}
function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: FocusState | null): FrameTarget {
const local = sub(frame.center, host.center);
if (frame.type === "NONE") return { position: local, opacity: 0, look: "hidden", interactive: false };
const open = focus !== null && !focus.isInsert && focus.id === host.id;
if (!open) {
return { position: local, opacity: focus ? GHOST_OPACITY : 1, look: "hint", interactive: false };
}
const raised = sameFrame(frame, view.hoveredFrame) || sameFrame(frame, view.selectedFrame);
return {
position: raised ? lift(local, FRAME_LIFT_RATIO * frame.size[1]) : local,
opacity: 1,
look: raised ? "type" : "glass",
interactive: true,
};
}
/** Targets for every part, insert and frame of the layout in the given view state. */
export function hiveMotionTargets(layout: HiveLayout, view: MotionView): HiveMotionTargets {
const focus = findFocus(layout, view);
const nodes = stackTargets(layout, view, focus);
for (const insert of layout.inserts) {
const host = hostOf(layout, insert);
if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host));
}
const frames = new Map<string, FrameTarget>();
for (const frame of layout.frames) {
const host = layout.parts.find((candidate) => candidate.id === frame.boxId);
if (host) frames.set(frameKey(frame.boxId, frame.slot), frameTarget(frame, host, view, focus));
}
return { nodes, frames, focus: focus ? focusInfo(layout, focus) : null };
}
/** Damping rate: about 95 % of the way after 250 ms, settled at about 350 ms (spec 9.8). */
export const DAMPING = 12;
/** Values closer than this count as arrived. */
export const SETTLE_EPSILON = 1e-3;
/**
* One frame of exponential damping toward a target (frame-rate independent),
* or the target itself when motion is not `full`.
*/
export function stepToward(current: number, target: number, deltaSeconds: number, damped: boolean): number {
if (!damped) return target;
const next = current + (target - current) * (1 - Math.exp(-DAMPING * deltaSeconds));
return Math.abs(target - next) < SETTLE_EPSILON ? target : next;
}
/** Linear fade used at motion level `subtle` (150 ms). */
export const SUBTLE_FADE_SECONDS = 0.15;
export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number {
if (seconds <= 0) return target;
const step = deltaSeconds / seconds;
if (Math.abs(target - current) <= step) return target;
return current + Math.sign(target - current) * step;
}
export interface IdleConditions {
motion: "full" | "subtle" | "off";
/** The card is in the viewport. */
visible: boolean;
/** The tab is in the foreground. */
tabVisible: boolean;
}
/** Idle float, pointer tilt and liquid waves run only at `full`, on screen, in the foreground tab. */
export function shouldIdle({ motion, visible, tabVisible }: IdleConditions): boolean {
return motion === "full" && visible && tabVisible;
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useTouchSelect } from "./use-touch-select";
function setup() {
const hover = vi.fn();
const select = vi.fn();
const { result, rerender } = renderHook(({ onHover, onSelect }) => useTouchSelect({ onHover, onSelect }), {
initialProps: { onHover: hover, onSelect: select },
});
return { hover, select, result, rerender };
}
describe("useTouchSelect", () => {
it("hovers on the first tap and selects on the second tap of the same target", () => {
const { hover, select, result } = setup();
result.current.tap("part:b1", "touch");
expect(hover).toHaveBeenLastCalledWith("part:b1");
expect(select).not.toHaveBeenCalled();
result.current.tap("part:b1", "touch");
expect(select).toHaveBeenCalledWith("part:b1");
});
it("starts over after a selection", () => {
const { select, result } = setup();
result.current.tap("part:b1", "touch");
result.current.tap("part:b1", "touch");
result.current.tap("part:b1", "touch");
expect(select).toHaveBeenCalledTimes(1);
});
it("moves the hover to another target instead of selecting", () => {
const { hover, select, result } = setup();
result.current.tap("part:b1", "touch");
result.current.tap("frame:b1#4", "touch");
expect(hover).toHaveBeenLastCalledWith("frame:b1#4");
expect(select).not.toHaveBeenCalled();
result.current.tap("frame:b1#4", "touch");
expect(select).toHaveBeenCalledWith("frame:b1#4");
});
it("resets on a tap elsewhere", () => {
const { hover, select, result } = setup();
result.current.tap("part:b1", "touch");
result.current.tap(null, "touch");
expect(hover).toHaveBeenLastCalledWith(null);
result.current.tap("part:b1", "touch");
expect(select).not.toHaveBeenCalled();
});
it("selects at once with a mouse or pen", () => {
const { hover, select, result } = setup();
result.current.tap("part:b1", "mouse");
expect(select).toHaveBeenCalledWith("part:b1");
result.current.tap("part:b2", "pen");
expect(select).toHaveBeenLastCalledWith("part:b2");
expect(hover).not.toHaveBeenCalled();
});
it("keeps a stable api and uses the latest handlers", () => {
const { result, rerender } = setup();
const first = result.current;
const nextSelect = vi.fn();
rerender({ onHover: vi.fn(), onSelect: nextSelect });
expect(result.current).toBe(first);
result.current.tap("part:x", "mouse");
expect(nextSelect).toHaveBeenCalledWith("part:x");
});
it("forgets the armed target on reset", () => {
const { select, result } = setup();
result.current.tap("part:b1", "touch");
result.current.reset();
result.current.tap("part:b1", "touch");
expect(select).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
// Spec 9.4: on touch the first tap hovers (the target lifts and shows its
// label), the second tap on the same target selects it; a tap elsewhere
// starts over. Mouse and pen select on click, hover comes from pointer-over.
export interface TouchSelectHandlers {
onHover(key: string | null): void;
onSelect(key: string): void;
}
export interface TouchSelect {
/** A tap or click on `key` (null: on nothing) with the pointer type of its pointerdown. */
tap(key: string | null, pointerType: string): void;
/** Forget the armed target (focus changed, scene left). */
reset(): void;
}
export function useTouchSelect(handlers: TouchSelectHandlers): TouchSelect {
const latest = useRef(handlers);
useEffect(() => {
latest.current = handlers;
});
const armed = useRef<string | null>(null);
return useMemo<TouchSelect>(
() => ({
tap(key, pointerType) {
if (pointerType !== "touch") {
armed.current = null;
if (key !== null) latest.current.onSelect(key);
return;
}
if (key !== null && armed.current === key) {
armed.current = null;
latest.current.onSelect(key);
return;
}
armed.current = key;
latest.current.onHover(key);
},
reset() {
armed.current = null;
},
}),
[],
);
}
@@ -0,0 +1,179 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hiveLayout } from "@/lib/hive/layout";
import { inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveVisualSlot } from "./hive-visual-slot";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const webgl = vi.hoisted(() => ({ canUseWebGL: vi.fn(), shouldUse3D: vi.fn() }));
vi.mock("@/lib/hive/webgl", () => webgl);
// The scene (three, WebGL) never runs in specs: next/dynamic returns a stub.
const canvasMounts = vi.hoisted(() => ({ count: 0 }));
vi.mock("next/dynamic", async () => {
const React = await import("react");
return {
default: () =>
function StubCanvas(props: { onReady: () => void; onContextLost: () => void }) {
React.useEffect(() => {
canvasMounts.count += 1;
}, []);
return (
<div data-testid="canvas">
<button onClick={props.onReady}>ready</button>
<button onClick={props.onContextLost}>lose</button>
</div>
);
},
};
});
const catalog = inlineCatalog();
const state: HiveState = { schemaVersion: 1, stack: [part("f", "floor"), part("r", "roof")] };
const layout = hiveLayout(state, catalog);
interface ObserverEntry {
callback: IntersectionObserverCallback;
disconnected: boolean;
}
let observed: ObserverEntry | null = null;
class FakeObserver {
entry: ObserverEntry;
constructor(callback: IntersectionObserverCallback) {
this.entry = { callback, disconnected: false };
observed = this.entry;
}
observe() {}
unobserve() {}
takeRecords() {
return [];
}
disconnect() {
this.entry.disconnected = true;
}
}
function enterViewport() {
act(() => {
observed!.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
}
function renderSlot() {
return render(
<HiveViewProvider store={createHiveViewStore()}>
<HiveVisualSlot
layout={layout}
state={state}
catalog={catalog}
partName={(id) => id}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
/>
</HiveViewProvider>,
);
}
function canvasLayer() {
return screen.getByTestId("canvas").parentElement!;
}
beforeEach(() => {
observed = null;
canvasMounts.count = 0;
webgl.canUseWebGL.mockReturnValue(true);
webgl.shouldUse3D.mockReturnValue(true);
vi.stubGlobal("IntersectionObserver", FakeObserver);
window.localStorage.removeItem("apicore.motion");
});
afterEach(() => {
vi.unstubAllGlobals();
window.localStorage.removeItem("apicore.motion");
});
describe("HiveVisualSlot", () => {
it("shows the SVG at once and no canvas before the card is visible", () => {
const { container } = renderSlot();
expect(container.querySelector("svg")).not.toBeNull();
expect(screen.queryByTestId("canvas")).toBeNull();
});
it("loads the scene when the card enters the viewport and 3D is allowed", () => {
renderSlot();
enterViewport();
expect(screen.getByTestId("canvas")).toBeInTheDocument();
expect(observed!.disconnected).toBe(true);
});
it("never loads the scene when shouldUse3D is false", () => {
webgl.shouldUse3D.mockReturnValue(false);
const { container } = renderSlot();
if (observed) enterViewport();
expect(screen.queryByTestId("canvas")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("passes WebGL support, motion level, cores and forced colors to shouldUse3D", () => {
window.localStorage.setItem("apicore.motion", "subtle");
renderSlot();
enterViewport();
expect(webgl.shouldUse3D).toHaveBeenCalledWith(
expect.objectContaining({ webgl: true, motion: "subtle", forcedColors: false }),
);
expect(typeof webgl.shouldUse3D.mock.calls.at(-1)![0].cores).toBe("number");
});
it("keeps the SVG visible until the scene is ready, then cross-fades (400 ms at full)", () => {
renderSlot();
enterViewport();
expect(canvasLayer().style.opacity).toBe("0");
fireEvent.click(screen.getByText("ready"));
expect(canvasLayer().style.opacity).toBe("1");
expect(canvasLayer().style.transitionDuration).toBe("400ms");
});
it("fades in 150 ms at subtle and instantly at off", () => {
window.localStorage.setItem("apicore.motion", "subtle");
const subtle = renderSlot();
enterViewport();
fireEvent.click(screen.getByText("ready"));
expect(canvasLayer().style.transitionDuration).toBe("150ms");
subtle.unmount();
window.localStorage.setItem("apicore.motion", "off");
renderSlot();
enterViewport();
fireEvent.click(screen.getByText("ready"));
expect(canvasLayer().style.transitionDuration).toBe("0ms");
});
it("lets the SVG keep the pointer until the scene is ready, then makes the scene interactive", () => {
renderSlot();
enterViewport();
expect(canvasLayer()).toHaveClass("pointer-events-none");
fireEvent.click(screen.getByText("ready"));
expect(canvasLayer()).not.toHaveClass("pointer-events-none");
});
it("remounts the scene once after the first context loss", () => {
renderSlot();
enterViewport();
expect(canvasMounts.count).toBe(1);
fireEvent.click(screen.getByText("lose"));
expect(screen.getByTestId("canvas")).toBeInTheDocument();
expect(canvasMounts.count).toBe(2);
expect(canvasLayer().style.opacity).toBe("0");
});
it("stays on the SVG after the second context loss", () => {
const { container } = renderSlot();
enterViewport();
fireEvent.click(screen.getByText("lose"));
fireEvent.click(screen.getByText("lose"));
expect(screen.queryByTestId("canvas")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
});
@@ -1,21 +1,99 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog } from "@/lib/hive/types";
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { HiveSvg } from "./hive-svg";
// three, fiber and friends live only in this chunk, fetched when the card is on screen.
const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false });
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: 150, off: 0 };
const FORCED_COLORS_QUERY = "(forced-colors: active)";
/** First context loss remounts the scene once, the second keeps the SVG for good. */
const MAX_CONTEXT_LOSSES = 1;
export interface HiveVisualSlotProps {
layout: HiveLayout;
/** The state the layout was made from (levels and choices for the 3D content). */
state: HiveState;
catalog: HiveCatalog;
partName: (id: string) => string;
onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void;
}
function allows3D(motion: MotionLevel): boolean {
return shouldUse3D({
webgl: canUseWebGL(),
motion,
cores: navigator.hardwareConcurrency ?? 0,
forcedColors: window.matchMedia?.(FORCED_COLORS_QUERY).matches ?? false,
});
}
/**
* The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as
* fallback) replaces this body without changing the props.
* The model of the hive. Shows the SVG at once; when the card is visible and
* the device allows it, the 3D scene loads and cross-fades over it and takes
* the pointer. Any failure to keep a context leaves the SVG, which is always
* rendered below.
*/
export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
return <HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />;
export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
const motion = useEffectiveMotionLevel();
const rootRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [losses, setLosses] = useState(0);
const [readyKey, setReadyKey] = useState<number | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setVisible(true);
observer.disconnect();
}
});
observer.observe(root);
return () => observer.disconnect();
}, []);
const show3D = visible && losses <= MAX_CONTEXT_LOSSES && allows3D(motion);
const ready = readyKey === losses;
const onReady = useCallback(() => setReadyKey(losses), [losses]);
const onContextLost = useCallback(() => setLosses((count) => count + 1), []);
return (
<div ref={rootRef} className="relative h-full w-full">
<HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />
{show3D ? (
<div
// Until the first frame is up the SVG below keeps the pointer.
className={`absolute inset-0 print:hidden ${ready ? "" : "pointer-events-none"}`}
data-testid="hive-3d-layer"
style={{
opacity: ready ? 1 : 0,
transitionProperty: "opacity",
transitionDuration: `${FADE_MS[motion]}ms`,
}}
>
<HiveCanvas
key={losses}
layout={layout}
state={state}
catalog={catalog}
partName={partName}
onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame}
onReady={onReady}
onContextLost={onContextLost}
/>
</div>
) : null}
</div>
);
}
@@ -29,10 +29,11 @@ interface HarnessProps {
initialState?: HiveState;
submit?: (ops: HiveOp[]) => Promise<unknown>;
startWithEvaporator?: boolean;
startWithFrameFeeder?: boolean;
}
/** Applies submitted ops to its own state, like the optimistic cache does. */
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) {
const [state, setState] = useState(initialState);
const [open, setOpen] = useState(true);
const outlineRef = useRef<HiveOutlineHandle>(null);
@@ -62,6 +63,7 @@ function Harness({ partId, initialState = baseState(), submit, startWithEvaporat
submit={send}
outlineRef={outlineRef}
startWithEvaporator={startWithEvaporator}
startWithFrameFeeder={startWithFrameFeeder}
/>
</HiveViewProvider>
);
@@ -329,4 +331,99 @@ describe("PartSheet", () => {
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
});
});
describe("frame feeder", () => {
const withPocket = (): HiveState => {
const s = baseState();
s.stack[1] = part("b", "box", {
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })],
});
return s;
};
it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="b" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
await user.click(screen.getByRole("radio", { name: "Plätze 3–4" }));
expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument();
const syrup = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(syrup);
await user.type(syrup, "1,5");
await user.click(save());
const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f));
expect(submit).toHaveBeenCalledWith([
{ op: "setFrames", boxId: "b", frames: expectedFrames },
{
op: "addInsert",
hostId: "b",
part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 },
},
]);
});
it("skips setFrames when the covered slots are already empty", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
const state = baseState();
state.stack[1] = part("b", "box", {
frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)),
});
render(<Harness partId="b" initialState={state} submit={submit} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
expect(screen.queryByText(/werden zu „leer“/)).toBeNull();
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{ op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) },
]);
});
it("offers only positions where the slots are still free", async () => {
const user = userEvent.setup();
render(<Harness partId="b" initialState={withPocket()} />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull();
expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument();
});
it("preselects the frame feeder when opened from the outline action", () => {
render(<Harness partId="b" startWithFrameFeeder />);
expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked();
});
it("moves focus to the new pocket item after inserting", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus());
});
it("edits the pocket level with a stepper", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument();
await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]);
});
it("asks before removing the pocket, then removes it and focuses the box", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
});
});
});
@@ -5,9 +5,12 @@ import type { RefObject } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { SheetFooter } from "@/components/ui/sheet";
import { freeFeederStarts } from "@/lib/hive/feeder-slots";
import { partDisplayName } from "@/lib/hive/names";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
import { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker";
import type { FrameFeederChoice } from "./frame-feeder-picker";
import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell";
import {
@@ -25,6 +28,11 @@ const INSERT_OPTIONS = [
{ value: true, label: "drin" },
{ value: false, label: "draußen" },
];
// Inserts the sheet can remove, with the noun used in its buttons.
const REMOVABLE_NOUNS: Partial<Record<HivePartKind, string>> = {
EVAPORATOR: "Verdunster",
FRAME_FEEDER: "Futtertasche",
};
const EVAPORATOR_OPTIONS = [
{ value: false, label: "Kein Verdunster" },
{ value: true, label: "Verdunster einsetzen" },
@@ -42,6 +50,8 @@ export interface PartSheetProps {
outlineRef: RefObject<HiveOutlineHandle | null>;
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
startWithEvaporator?: boolean;
/** Opens a box with the frame feeder already chosen (the outline's „Futtertasche einsetzen“ action). */
startWithFrameFeeder?: boolean;
}
interface Located {
@@ -78,18 +88,26 @@ interface PartFormProps {
type: CatalogPartType;
catalog: HiveCatalog;
startWithEvaporator: boolean;
startWithFrameFeeder: boolean;
submit: SubmitOps;
/** `focusId` is the part whose item should get focus once the sheet is closed. */
onDone: (focusId: string) => void;
}
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameFeeder, submit, onDone }: PartFormProps) {
const { part, host } = located;
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
const evaporatorType =
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
const frameFeederType = type.kind === "BOX" ? activeTypeOf(catalog, "FRAME_FEEDER") : undefined;
const feederStarts = frameFeederType ? freeFeederStarts(part, type, frameFeederType, catalog) : [];
const [feederChoice, setFeederChoice] = useState<FrameFeederChoice>(() => ({
wants: startWithFrameFeeder && feederStarts.length > 0,
start: feederStarts[0] ?? 1,
draft: frameFeederType ? draftOf(frameFeederType, {}) : {},
}));
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
@@ -108,8 +126,13 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
wasConfirming.current = confirmingRemoval;
}, [confirmingRemoval]);
const { send, pending, error } = useSheetSubmit(submit);
const removalNoun = REMOVABLE_NOUNS[type.kind] ?? null;
// Fixed per form, so a retry after an error sends the same ids.
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
const [newIds] = useState(() => ({
floorInsert: newIdempotencyKey(),
evaporator: newIdempotencyKey(),
frameFeeder: newIdempotencyKey(),
}));
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
@@ -124,19 +147,29 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
}
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
const feederToInsert =
frameFeederType && feederChoice.wants && feederStarts.includes(feederChoice.start)
? newPart(newIds.frameFeeder, frameFeederType, feederChoice.draft)
: null;
if (frameFeederType && feederToInsert) {
ops.push(...frameFeederOps(part.id, part.frames ?? [], frameFeederType, feederChoice.start, feederToInsert));
}
const valid =
areDraftFieldsValid(type, draft) &&
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft)) &&
(!frameFeederType || !feederChoice.wants || areDraftFieldsValid(frameFeederType, feederChoice.draft));
async function save() {
// After inserting an evaporator, focus goes to the new item.
// After inserting an evaporator or a frame feeder, focus goes to the new item.
const outcome = await send(ops);
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
if (outcome !== "failed") {
onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? feederToInsert?.id ?? part.id) : part.id);
}
}
async function remove() {
// An evaporator is an insert: focus goes to its box afterwards.
// An evaporator or frame feeder is an insert: focus goes to its box afterwards.
const outcome = await send([{ op: "removePart", id: part.id }]);
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
}
@@ -197,6 +230,16 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
: null}
</div>
) : null}
{frameFeederType && feederStarts.length > 0 ? (
<FrameFeederPicker
boxId={part.id}
type={frameFeederType}
frames={part.frames ?? []}
starts={feederStarts}
choice={feederChoice}
onChange={setFeederChoice}
/>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -206,7 +249,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
Speichern
</Button>
{type.kind === "EVAPORATOR" ? (
{removalNoun ? (
confirmingRemoval ? (
<div
role="group"
@@ -214,7 +257,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
>
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
Verdunster wirklich entfernen?
{removalNoun} wirklich entfernen?
</p>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
@@ -233,7 +276,7 @@ function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone
size="touch"
onClick={() => setConfirmingRemoval(true)}
>
Verdunster entfernen
{removalNoun} entfernen
</Button>
)
) : null}
@@ -255,6 +298,7 @@ export function PartSheet({
submit,
outlineRef,
startWithEvaporator = false,
startWithFrameFeeder = false,
}: PartSheetProps) {
const located = locate(state, partId);
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
@@ -279,6 +323,7 @@ export function PartSheet({
type={type}
catalog={catalog}
startWithEvaporator={startWithEvaporator}
startWithFrameFeeder={startWithFrameFeeder}
submit={submit}
onDone={(id) => {
focusAfterSave.current = id;
@@ -1,13 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
describe("createHiveViewStore", () => {
it("starts empty and keeps the same state object until something changes", () => {
const store = createHiveViewStore();
const first = store.getState();
expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null });
expect(first).toEqual({ hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null });
store.setHovered(null);
expect(store.getState()).toBe(first);
store.setHovered("a");
@@ -42,6 +42,27 @@ describe("createHiveViewStore", () => {
expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null });
});
it("focusPart focuses a non-box part and closes an open box", () => {
const store = createHiveViewStore();
store.openBox("b");
store.setHoveredFrame({ boxId: "b", slot: 2 });
store.focusPart("fe");
expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: "fe", hoveredFrame: null });
expect(focusedIdOf(store.getState())).toBe("fe");
});
it("openBox replaces a focused part, openBox(null) leaves focus mode completely", () => {
const store = createHiveViewStore();
store.focusPart("fe");
store.openBox("b");
expect(store.getState()).toMatchObject({ openBoxId: "b", focusedPartId: null });
expect(focusedIdOf(store.getState())).toBe("b");
store.focusPart("fe");
store.openBox(null);
expect(store.getState()).toMatchObject({ openBoxId: null, focusedPartId: null });
expect(focusedIdOf(store.getState())).toBeNull();
});
it("treats an equal frame as no change", () => {
const store = createHiveViewStore();
store.setHoveredFrame({ boxId: "b", slot: 2 });
@@ -63,6 +84,8 @@ describe("useHiveView", () => {
expect(result.current.hoveredId).toBe("x");
act(() => result.current.openBox("b"));
expect(result.current.openBoxId).toBe("b");
act(() => result.current.focusPart("fe"));
expect(result.current.focusedPartId).toBe("fe");
});
it("throws outside a provider", () => {
@@ -13,22 +13,36 @@ export interface HoveredFrame {
export interface HiveViewState {
hoveredId: string | null;
/** The box in focus mode (its frames are shown). */
openBoxId: string | null;
/** Any other part in focus mode (feeder, floor, lid, insert …); never set together with openBoxId. */
focusedPartId: string | null;
hoveredFrame: HoveredFrame | null;
}
/** The part in focus mode, box or not; null in the rest view. */
export function focusedIdOf(state: HiveViewState): string | null {
return state.openBoxId ?? state.focusedPartId;
}
export interface HiveViewStore {
getState(): HiveViewState;
subscribe(listener: () => void): () => void;
setHovered(id: string | null): void;
/** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */
/**
* Opens a box (clears the hovered frame and any focused part); null leaves
* focus mode completely (no open box, no focused part, no hovered frame).
*/
openBox(id: string | null): void;
/** Focuses a part that is not a box (closes an open box, clears the hovered frame). */
focusPart(id: string): void;
setHoveredFrame(frame: HoveredFrame | null): void;
}
export interface HiveView extends HiveViewState {
setHovered(id: string | null): void;
openBox(id: string | null): void;
focusPart(id: string): void;
setHoveredFrame(frame: HoveredFrame | null): void;
}
@@ -36,7 +50,7 @@ const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean =>
a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot);
export function createHiveViewStore(): HiveViewStore {
let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null };
let state: HiveViewState = { hoveredId: null, openBoxId: null, focusedPartId: null, hoveredFrame: null };
const listeners = new Set<() => void>();
// The state object only changes when a value really changes, so
@@ -46,6 +60,7 @@ export function createHiveViewStore(): HiveViewStore {
if (
next.hoveredId === state.hoveredId &&
next.openBoxId === state.openBoxId &&
next.focusedPartId === state.focusedPartId &&
sameFrame(next.hoveredFrame, state.hoveredFrame)
) {
return;
@@ -63,7 +78,8 @@ export function createHiveViewStore(): HiveViewStore {
};
},
setHovered: (id) => update({ hoveredId: id }),
openBox: (id) => update({ openBoxId: id, hoveredFrame: null }),
openBox: (id) => update({ openBoxId: id, focusedPartId: null, hoveredFrame: null }),
focusPart: (id) => update({ openBoxId: null, focusedPartId: id, hoveredFrame: null }),
setHoveredFrame: (frame) => update({ hoveredFrame: frame }),
};
}
@@ -83,6 +99,7 @@ export function useHiveView(): HiveView {
...state,
setHovered: store.setHovered,
openBox: store.openBox,
focusPart: store.focusPart,
setHoveredFrame: store.setHoveredFrame,
}),
[state, store],