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
+9404 -181

No files matched your search

@@ -0,0 +1,10 @@
-- Beute in 3D: Futtertasche (FRAME_FEEDER, spec 2026-10-15 sections 4.1, 4.3,
-- 5.1). A frame feeder hangs in a box in place of frames and covers
-- `slotWidth` frame slots. The enum value cannot be used in the migration that
-- adds it, so the catalog row comes in 20261018100100.
-- AlterEnum
ALTER TYPE "HivePartKind" ADD VALUE 'FRAME_FEEDER';
-- AlterTable
ALTER TABLE "HivePartType" ADD COLUMN "slotWidth" INTEGER;
@@ -0,0 +1,20 @@
-- Beute in 3D: start catalog row for the Futtertasche (owner wish 2026-10-16,
-- spec sections 4.3 and 4.1). Fixed id like the other start data; dimensions and
-- the 2 l capacity are assumptions the owner confirms (catalog data, editable).
INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "slotWidth", "stateSchema", "active", "updatedAt")
VALUES (
'hive-part-zl10-frame-feeder',
'hive-system-zander-liebig-10',
'FRAME_FEEDER',
'Holz-Futtertasche Zander, doppelte Wabenbreite',
477,
70,
220,
NULL,
NULL,
2,
'[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":2,"step":0.25,"feedType":"SUGAR_WATER"}]'::jsonb,
true,
CURRENT_TIMESTAMP
);
+2
View File
@@ -2217,6 +2217,7 @@ model HivePartType {
heightMm Int
boxDepth HiveBoxDepth?
frameSlots Int? // nur BOX
slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze
stateSchema Json // Abschnitt 4.2
active Boolean @default(true)
createdAt DateTime @default(now())
@@ -2269,6 +2270,7 @@ enum HivePartKind {
ROOF
FLOOR_INSERT
EVAPORATOR
FRAME_FEEDER
}
enum HiveBoxDepth {
@@ -26,12 +26,14 @@ export const HIVE_PART_KINDS = [
'ROOF',
'FLOOR_INSERT',
'EVAPORATOR',
'FRAME_FEEDER',
] as const;
export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const;
const MAX_MM = 5000;
const MAX_SCHEMA_FIELDS = 30;
const MAX_FRAME_SLOTS = 20;
const MAX_SLOT_WIDTH = 5;
export class CreateHivePartTypeDto {
@ApiProperty({ enum: HIVE_PART_KINDS })
@@ -80,6 +82,15 @@ export class CreateHivePartTypeDto {
@Max(MAX_FRAME_SLOTS)
frameSlots?: number;
@ApiPropertyOptional({
description: 'Required for kind FRAME_FEEDER, forbidden otherwise (1-5)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SLOT_WIDTH)
slotWidth?: number;
@ApiPropertyOptional({ description: 'State fields, spec 4.2' })
@IsOptional()
@IsArray()
@@ -454,5 +454,61 @@ describe('HiveCatalogService', () => {
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requires slotWidth for a FRAME_FEEDER', async () => {
await expect(
service.createPartType(
SYSTEM_ID,
{
kind: 'FRAME_FEEDER',
name: 'Tasche',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
},
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects slotWidth on a part type that is no FRAME_FEEDER', async () => {
await expect(
service.createPartType(
SYSTEM_ID,
{
kind: 'ROOF',
name: 'Dach',
lengthMm: 1,
widthMm: 1,
heightMm: 1,
slotWidth: 2,
},
USER,
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.updatePartType('pt-1', { slotWidth: 2 }, USER),
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates a FRAME_FEEDER with its slotWidth', async () => {
tx.hivePartType.create.mockResolvedValue({ id: 'new' });
await service.createPartType(
SYSTEM_ID,
{
kind: 'FRAME_FEEDER',
name: 'Tasche',
lengthMm: 477,
widthMm: 70,
heightMm: 220,
slotWidth: 2,
},
USER,
);
const [{ data }] = tx.hivePartType.create.mock.calls[0] as [
{ data: { kind: string; slotWidth: number } },
];
expect(data).toMatchObject({ kind: 'FRAME_FEEDER', slotWidth: 2 });
});
});
});
@@ -92,6 +92,7 @@ export class HiveCatalogService {
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
slotWidth: row.slotWidth,
stateSchema: parseStateSchema(row.stateSchema),
active: row.active,
})),
@@ -191,6 +192,7 @@ export class HiveCatalogService {
if (dto.kind === 'BOX' && dto.frameSlots === undefined) {
throw new BadRequestException('frameSlots is required for a BOX');
}
this.assertSlotWidth(dto.kind, dto.slotWidth);
const stateSchema = dto.stateSchema ?? [];
this.assertSchemaShape(stateSchema);
return this.prisma
@@ -233,6 +235,7 @@ export class HiveCatalogService {
const before = await tx.hivePartType.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Hive part type not found');
const { stateSchema, ...rest } = dto;
this.assertSlotWidth(before.kind, dto.slotWidth ?? before.slotWidth);
if (stateSchema !== undefined) {
this.assertSchemaShape(stateSchema);
this.assertAdditive(before.stateSchema, stateSchema);
@@ -262,6 +265,21 @@ export class HiveCatalogService {
.catch((error: unknown) => conflictOnDuplicate(error, 'Part type'));
}
/** slotWidth belongs to FRAME_FEEDER part types and to no other kind. */
private assertSlotWidth(
kind: string,
slotWidth: number | null | undefined,
): void {
if (kind === 'FRAME_FEEDER' && slotWidth == null) {
throw new BadRequestException('slotWidth is required for a FRAME_FEEDER');
}
if (kind !== 'FRAME_FEEDER' && slotWidth != null) {
throw new BadRequestException(
'slotWidth is only allowed for a FRAME_FEEDER',
);
}
}
private assertSchemaShape(stateSchema: unknown): void {
const problems = stateSchemaProblems(stateSchema);
if (problems.length > 0) {
@@ -115,4 +115,32 @@ describe('deriveHiveFacts', () => {
};
expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0);
});
it('reports a frame feeder level with its feed type', () => {
const state = stackOf({
...makeBox(['NONE', 'NONE', ...Array<FrameType>(8).fill('BROOD')]),
inserts: [
{
id: 'ff1',
partTypeId: T('frame-feeder'),
slot: 1,
state: { syrupLiters: 1.5 },
},
],
});
const facts = deriveHiveFacts(state, catalog);
expect(facts.levels).toEqual([
{
partId: 'ff1',
key: 'syrupLiters',
label: 'Flüssigfutter',
unit: 'l',
value: 1.5,
max: 2,
feedType: 'SUGAR_WATER',
},
]);
// The covered NONE slots are not built frames.
expect(facts.builtFrames).toBe(8);
});
});
+6 -1
View File
@@ -13,7 +13,8 @@ export type HivePartKind =
| 'INSULATION'
| 'ROOF'
| 'FLOOR_INSERT'
| 'EVAPORATOR';
| 'EVAPORATOR'
| 'FRAME_FEEDER';
// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on.
export type HiveBoxDepth = 'FULL' | 'SHALLOW';
@@ -61,6 +62,8 @@ export interface HivePart {
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
// Only on FRAME_FEEDER inserts: first frame slot (1-based) the pocket covers.
slot?: number;
}
export interface HiveState {
@@ -112,6 +115,8 @@ export interface CatalogPartType {
widthMm: number;
heightMm: number;
frameSlots: number | null;
// Only FRAME_FEEDER: number of consecutive frame slots the pocket covers.
slotWidth: number | null;
stateSchema: StateField[];
active: boolean;
}
+113 -13
View File
@@ -10,10 +10,21 @@ import type { CatalogPartType, HiveCatalog, StateField } from './hive-types';
// keep it: root shape (schemaVersion must be 1 -> STATE_OUT_OF_RANGE; extra
// root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per stack part: id, unknown
// fields (UNKNOWN_FIELD), type, label, state, frames (per frame: slot, unknown
// fields, type, name), inserts (each insert in the same order), then FLOOR, then ROOF.
// fields, type, name), inserts (each insert in the same order; then per insert
// RULE_INSERT_HOST, nested inserts and `slot` / RULE_FEEDER_SLOT; after all
// inserts of a box the FRAME_FEEDER overlap check, also RULE_FEEDER_SLOT),
// then FLOOR, then ROOF.
// Text lengths are UTF-16 code units (`.length`); the frontend must do the same.
const ROOT_KEYS = ['schemaVersion', 'stack'];
const PART_KEYS = ['id', 'partTypeId', 'label', 'state', 'inserts', 'frames'];
const PART_KEYS = [
'id',
'partTypeId',
'label',
'state',
'inserts',
'frames',
'slot',
];
const FRAME_KEYS = ['slot', 'type', 'name'];
function unknownFields(
@@ -168,6 +179,80 @@ function validatePartCommon(
return { part, type };
}
const INSERT_KINDS: Partial<Record<string, string[]>> = {
FLOOR: ['FLOOR_INSERT'],
BOX: ['EVAPORATOR', 'FRAME_FEEDER'],
};
interface SlotRange {
from: number;
to: number;
}
/**
* RULE_FEEDER_SLOT checks for one insert: `slot` only on FRAME_FEEDER, an
* integer inside 1..frameSlots for the covered width, and every covered frame
* NONE. Returns the covered range for the overlap check, or null.
*/
function validateFeederSlot(
insert: Obj,
type: CatalogPartType,
host: Obj,
hostType: CatalogPartType,
where: string,
out: string[],
): SlotRange | null {
if (type.kind !== 'FRAME_FEEDER') {
if (insert.slot !== undefined) {
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
}
return null;
}
if (hostType.kind !== 'BOX') return null;
const slot = insert.slot;
const width = type.slotWidth;
const slots = hostType.frameSlots ?? 0;
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} needs an integer slot of 1 or more`);
return null;
}
if (width === null || !Number.isInteger(width) || width < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} part type has no slot width`);
return null;
}
const range = { from: slot, to: slot + width - 1 };
if (range.to > slots) {
out.push(
`RULE_FEEDER_SLOT: ${where} covers slots ${range.from}-${range.to} but the box has ${slots}`,
);
return null;
}
const frames = Array.isArray(host.frames) ? (host.frames as Obj[]) : [];
for (let at = range.from; at <= range.to; at++) {
if (frames[at - 1]?.type !== 'NONE') {
out.push(
`RULE_FEEDER_SLOT: ${where} covers frame ${at} which is not NONE`,
);
return null;
}
}
return range;
}
function validateFeederOverlap(
ranges: SlotRange[],
where: string,
out: string[],
): void {
const sorted = [...ranges].sort((a, b) => a.from - b.from);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].from <= sorted[i - 1].to) {
out.push(`RULE_FEEDER_SLOT: ${where} has overlapping frame feeders`);
return;
}
}
}
function validateInserts(
host: Obj,
hostType: CatalogPartType,
@@ -181,24 +266,25 @@ function validateInserts(
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed =
hostType.kind === 'FLOOR'
? 'FLOOR_INSERT'
: hostType.kind === 'BOX'
? 'EVAPORATOR'
: null;
const allowed = INSERT_KINDS[hostType.kind] ?? null;
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
if (hostType.kind === 'FLOOR' && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one FLOOR_INSERT`);
}
const ranges: SlotRange[] = [];
let evaporators = 0;
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
if (checked.type.kind === 'EVAPORATOR') evaporators += 1;
if (evaporators > 1 && checked.type.kind === 'EVAPORATOR') {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one EVAPORATOR`);
}
if (allowed !== null && !allowed.includes(checked.type.kind)) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed.join(' or ')}`);
}
if (checked.part.inserts !== undefined) {
const nested = checked.part.inserts;
@@ -206,7 +292,17 @@ function validateInserts(
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
}
const range = validateFeederSlot(
checked.part,
checked.type,
host,
hostType,
here,
out,
);
if (range) ranges.push(range);
});
validateFeederOverlap(ranges, where, out);
}
function validateStackRules(
@@ -256,12 +352,16 @@ export function validateHiveState(
if (!checked) return;
if (
checked.type.kind === 'FLOOR_INSERT' ||
checked.type.kind === 'EVAPORATOR'
checked.type.kind === 'EVAPORATOR' ||
checked.type.kind === 'FRAME_FEEDER'
) {
out.push(
`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`,
);
}
if (checked.part.slot !== undefined) {
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
+6
View File
@@ -689,6 +689,12 @@ describe('Backup round trip (e2e)', () => {
expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe(
true,
);
// The frame feeder's slotWidth travels with the part type rows.
expect(
exportedHive.hivePartType.find(
(row) => row.id === 'hive-part-zl10-frame-feeder',
)?.slotWidth,
).toBe(2);
// The colony is lost; the catalog rows stay, as a fresh install has
// them from the migration.
@@ -0,0 +1,81 @@
{
"name": "frame feeder without slot fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {}
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder slot 0 fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 0
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder with a fractional slot fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "NONE"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1.5
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,126 @@
{
"name": "setFrames putting a type on a covered slot fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "setFrames",
"boxId": "b1",
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,156 @@
{
"name": "setState on a frame feeder sets the level",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "setState",
"id": "p1",
"key": "syrupLiters",
"value": 2
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {
"syrupLiters": 2
},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,86 @@
{
"name": "setState on a frame feeder above max fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "setState",
"id": "p1",
"key": "syrupLiters",
"value": 2.25
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "STATE_OUT_OF_RANGE"
}
}
@@ -0,0 +1,162 @@
{
"name": "evaporator and frame feeder share a box",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "e1",
"partTypeId": "hive-part-zl10-evaporator",
"state": {}
}
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
},
{
"id": "e1",
"partTypeId": "hive-part-zl10-evaporator",
"state": {}
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,82 @@
{
"name": "slot on a non-feeder insert fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "e1",
"partTypeId": "hive-part-zl10-evaporator",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,40 @@
{
"name": "frame feeder in a box without slots fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b0",
"partTypeId": "hive-part-zl10-box-empty",
"state": {}
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b0",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,94 @@
{
"name": "two evaporators still fail next to a frame feeder",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
},
{
"id": "e1",
"partTypeId": "hive-part-zl10-evaporator",
"state": {}
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "e2",
"partTypeId": "hive-part-zl10-evaporator",
"state": {}
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_INSERT_LIMIT"
}
}
@@ -0,0 +1,150 @@
{
"name": "frame feeder at slot 1 over two NONE frames is ok",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,200 @@
{
"name": "setFrames then addInsert places a frame feeder",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "setFrames",
"boxId": "b1",
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "NONE"
},
{
"slot": 5,
"type": "NONE"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {
"syrupLiters": 1.5
},
"slot": 4
}
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "NONE"
},
{
"slot": 5,
"type": "NONE"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {
"syrupLiters": 1.5
},
"slot": 4
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder over frames that are not NONE fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,90 @@
{
"name": "overlapping frame feeders fail",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "NONE"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p2",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 2
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder beyond frameSlots fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "NONE"
},
{
"slot": 10,
"type": "NONE"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 10
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,86 @@
{
"name": "setFrame on a covered slot fails",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "setFrame",
"boxId": "b1",
"slot": 2,
"type": "BROOD"
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_FEEDER_SLOT"
}
}
@@ -0,0 +1,150 @@
{
"name": "removing the frame feeder frees its slots",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "removePart",
"id": "p1"
},
{
"op": "setFrame",
"boxId": "b1",
"slot": 1,
"type": "BROOD"
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "BROOD"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,164 @@
{
"name": "two frame feeders without overlap are ok",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "NONE"
},
{
"slot": 6,
"type": "NONE"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "b1",
"part": {
"id": "p2",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 5
}
}
],
"expect": {
"ok": true,
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "NONE"
},
{
"slot": 2,
"type": "NONE"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "NONE"
},
{
"slot": 6,
"type": "NONE"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
],
"inserts": [
{
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
},
{
"id": "p2",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 5
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
}
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder cannot sit in the stack",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addPart",
"at": 2,
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_INSERT_HOST"
}
}
@@ -0,0 +1,82 @@
{
"name": "frame feeder cannot go into a floor",
"state": {
"schemaVersion": 1,
"stack": [
{
"id": "f1",
"partTypeId": "hive-part-zl10-floor",
"state": {}
},
{
"id": "b1",
"partTypeId": "hive-part-zl10-box-10",
"state": {},
"frames": [
{
"slot": 1,
"type": "FOUNDATION"
},
{
"slot": 2,
"type": "FOUNDATION"
},
{
"slot": 3,
"type": "FOUNDATION"
},
{
"slot": 4,
"type": "FOUNDATION"
},
{
"slot": 5,
"type": "FOUNDATION"
},
{
"slot": 6,
"type": "FOUNDATION"
},
{
"slot": 7,
"type": "FOUNDATION"
},
{
"slot": 8,
"type": "FOUNDATION"
},
{
"slot": 9,
"type": "FOUNDATION"
},
{
"slot": 10,
"type": "FOUNDATION"
}
]
},
{
"id": "r1",
"partTypeId": "hive-part-zl10-roof",
"state": {}
}
]
},
"ops": [
{
"op": "addInsert",
"hostId": "f1",
"part": {
"id": "p1",
"partTypeId": "hive-part-zl10-frame-feeder",
"state": {},
"slot": 1
}
}
],
"expect": {
"ok": false,
"opIndex": 0,
"reasonCode": "RULE_INSERT_HOST"
}
}
+36
View File
@@ -9,6 +9,7 @@
"widthMm": 430,
"heightMm": 120,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [
{
"key": "entrance",
@@ -49,6 +50,7 @@
"widthMm": 375,
"heightMm": 5,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -60,6 +62,7 @@
"widthMm": 430,
"heightMm": 230,
"frameSlots": 10,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -71,6 +74,7 @@
"widthMm": 430,
"heightMm": 230,
"frameSlots": 0,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -82,6 +86,7 @@
"widthMm": 430,
"heightMm": 50,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [
{
"key": "bungHoles",
@@ -109,6 +114,7 @@
"widthMm": 376,
"heightMm": 2,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -120,6 +126,7 @@
"widthMm": 420,
"heightMm": 70,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [
{
"key": "syrupLiters",
@@ -150,6 +157,7 @@
"widthMm": 378,
"heightMm": 1,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -161,6 +169,7 @@
"widthMm": 378,
"heightMm": 1,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -172,6 +181,7 @@
"widthMm": 378,
"heightMm": 3,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -183,6 +193,7 @@
"widthMm": 430,
"heightMm": 35,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [
{
"key": "feedHole",
@@ -208,6 +219,7 @@
"widthMm": 365,
"heightMm": 20,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -219,6 +231,7 @@
"widthMm": 430,
"heightMm": 60,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [],
"active": true
},
@@ -230,6 +243,7 @@
"widthMm": 140,
"heightMm": 120,
"frameSlots": null,
"slotWidth": null,
"stateSchema": [
{
"key": "acidMl",
@@ -241,6 +255,28 @@
}
],
"active": true
},
{
"id": "hive-part-zl10-frame-feeder",
"kind": "FRAME_FEEDER",
"name": "Holz-Futtertasche Zander, doppelte Wabenbreite",
"lengthMm": 477,
"widthMm": 70,
"heightMm": 220,
"frameSlots": null,
"slotWidth": 2,
"stateSchema": [
{
"key": "syrupLiters",
"label": "Flüssigfutter",
"kind": "level",
"unit": "l",
"max": 2,
"step": 0.25,
"feedType": "SUGAR_WATER"
}
],
"active": true
}
]
}
@@ -20,6 +20,7 @@ const PART_SLUGS = [
'insulation',
'roof',
'evaporator',
'frame-feeder',
] as const;
interface StateField {
@@ -66,7 +67,7 @@ describe('Hive catalog seed (e2e)', () => {
});
});
it('seeds 14 active part types for the system under fixed ids', async () => {
it('seeds 15 active part types for the system under fixed ids', async () => {
const types = await prisma.hivePartType.findMany({
where: { systemId: SYSTEM_ID, active: true },
orderBy: { id: 'asc' },
@@ -103,6 +104,31 @@ describe('Hive catalog seed (e2e)', () => {
]);
});
it('seeds the frame feeder over two frame slots with a 2 l syrup level', async () => {
const pocket = await partType('frame-feeder');
const fields = pocket.stateSchema as unknown as StateField[];
expect(pocket).toMatchObject({
kind: 'FRAME_FEEDER',
slotWidth: 2,
frameSlots: null,
lengthMm: 477,
widthMm: 70,
heightMm: 220,
});
expect(fields).toEqual([
{
key: 'syrupLiters',
label: 'Flüssigfutter',
kind: 'level',
unit: 'l',
max: 2,
step: 0.25,
feedType: 'SUGAR_WATER',
},
]);
});
it('gives the other stateful parts their choice and level fields', async () => {
const keysOf = async (slug: string) =>
((await partType(slug)).stateSchema as unknown as StateField[]).map(
@@ -20,6 +20,7 @@ interface FixturePartType {
widthMm: number;
heightMm: number;
frameSlots: number | null;
slotWidth: number | null;
stateSchema: unknown[];
active: boolean;
}
@@ -156,6 +157,7 @@ describe('Hive catalog (e2e)', () => {
widthMm: row?.widthMm,
heightMm: row?.heightMm,
frameSlots: row?.frameSlots,
slotWidth: row?.slotWidth,
stateSchema: row?.stateSchema,
active: row?.active,
}).toEqual(expected);
+710 -3
View File
@@ -9,23 +9,30 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.7.0",
"@react-three/drei": "10.7.9",
"@react-three/fiber": "9.8.1",
"@react-three/postprocessing": "3.1.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"motion": "13.4.6",
"next": "16.3.1",
"next-themes": "^0.4.6",
"postprocessing": "6.39.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-leaflet": "^5.0.0",
"shadcn": "^4.18.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"three": "0.186.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@react-three/test-renderer": "9.1.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
@@ -34,6 +41,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
@@ -776,6 +784,12 @@
"node": ">=20.19.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.75.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz",
@@ -2357,6 +2371,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.17",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -2419,6 +2439,18 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2706,6 +2738,128 @@
"react-dom": "^19.0.0"
}
},
"node_modules/@react-three/drei": {
"version": "10.7.9",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.9.tgz",
"integrity": "sha512-wP4Y6o58KFi+hT1zCfjJarRtPCT6WW/lo53WMiA/aCORuUvBzW916XsCtadnIB1KB+3IQNcDU5JeVh/gYb8MYw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@mediapipe/tasks-vision": "0.10.17",
"@monogrid/gainmap-js": "^3.0.6",
"@use-gesture/react": "^10.3.1",
"camera-controls": "^3.1.0",
"cross-env": "^7.0.3",
"detect-gpu": "^5.0.56",
"glsl-noise": "^0.0.0",
"hls.js": "^1.5.17",
"maath": "^0.10.8",
"meshline": "^3.3.1",
"stats-gl": "^2.2.8",
"stats.js": "^0.17.0",
"suspend-react": "^0.1.3",
"three-mesh-bvh": "^0.8.3",
"three-stdlib": "^2.35.6",
"troika-three-text": "^0.52.4",
"tunnel-rat": "^0.1.2",
"use-sync-external-store": "^1.4.0",
"utility-types": "^3.11.0",
"zustand": "^5.0.1"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19",
"react-dom": "^19",
"three": ">=0.159"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/@react-three/fiber": {
"version": "9.8.1",
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.8.1.tgz",
"integrity": "sha512-GbNP4r6F/gpclFKkLGHzXcuwFWAzLyKsmrQGcDuwK3wQnwLH6F1TfGtEYjHy8OQoGsajwT19B9jtVLLjjfH09w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"its-fine": "^2.1.1",
"react-use-measure": "^2.1.7",
"scheduler": "^0.28.0",
"suspend-react": "^0.1.3",
"use-sync-external-store": "^1.4.0",
"zustand": "^5.0.3"
},
"peerDependencies": {
"expo": ">=43.0",
"expo-asset": ">=8.4",
"expo-file-system": ">=11.0",
"expo-gl": ">=11.0",
"react": ">=19 <19.4",
"react-dom": ">=19 <19.4",
"react-native": ">=0.78",
"three": ">=0.156"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"expo-asset": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-gl": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@react-three/fiber/node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/@react-three/postprocessing": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.1.3.tgz",
"integrity": "sha512-LhCTSC+nli1agIIIgrhJalvJcdSy7/DOXG5EDGxKVlVsrqUxMPYlMnRN/QqgSAjGPKmnnuiEmpVQtOkTKwiidw==",
"license": "MIT",
"dependencies": {
"maath": "^0.10.8",
"n8ao": "^2.0.0"
},
"peerDependencies": {
"@react-three/fiber": ">=9.7.0",
"postprocessing": "^6.36.0",
"react": "^19.0.0",
"three": ">= 0.156.0"
}
},
"node_modules/@react-three/test-renderer": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@react-three/test-renderer/-/test-renderer-9.1.1.tgz",
"integrity": "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@react-three/fiber": ">=9.0.0",
"react": "^19.0.0",
"three": ">=0.156"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -3703,6 +3857,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -3785,6 +3945,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/draco3d": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3833,11 +3999,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3853,12 +4024,47 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.186.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
"integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.3",
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/validate-npm-package-name": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz",
@@ -4508,6 +4714,24 @@
"win32"
]
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -5035,6 +5259,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
@@ -5051,7 +5295,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
@@ -5150,6 +5393,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -5231,6 +5498,19 @@
"node": ">=6"
}
},
"node_modules/camera-controls": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
"license": "MIT",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.5.1"
},
"peerDependencies": {
"three": ">=0.126.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001809",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
@@ -5544,6 +5824,24 @@
}
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5595,7 +5893,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -5853,6 +6150,15 @@
"node": ">=6"
}
},
"node_modules/detect-gpu": {
"version": "5.0.70",
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
"license": "MIT",
"dependencies": {
"webgl-constants": "^1.1.1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5920,6 +6226,12 @@
"url": "https://dotenvx.com"
}
},
"node_modules/draco3d": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.8.tgz",
"integrity": "sha512-rmmKiIgnv4RKB1hSze/9yRql3+5cxkxn49FUMWfhsJ5ob5tSMyfe0K/aRZsh6HoN/XveZXMjouO/gGK+JgqYPw==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6936,6 +7248,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -7060,6 +7378,29 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "13.5.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.5.1.tgz",
"integrity": "sha512-HeWOaeHD3kyTSIEMT6+nw7fBn4saIfxxabZyAITbycAl4xr1kzGJKIvGt/OWghSlvIA9qTnZ1zTGelHAe6S8DA==",
"license": "MIT",
"dependencies": {
"motion-dom": "13.5.1",
"motion-utils": "13.5.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -7317,6 +7658,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glsl-noise": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7444,6 +7791,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/hono": {
"version": "4.13.3",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz",
@@ -7517,6 +7870,26 @@
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7526,6 +7899,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8208,6 +8587,18 @@
"node": ">= 0.4"
}
},
"node_modules/its-fine": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.1.1.tgz",
"integrity": "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.9"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -8460,6 +8851,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -8844,6 +9244,16 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/maath": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.134.0",
"three": ">=0.134.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8910,6 +9320,21 @@
"node": ">= 8"
}
},
"node_modules/meshline": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.137"
}
},
"node_modules/meshoptimizer": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -9001,12 +9426,59 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "13.4.6",
"resolved": "https://registry.npmjs.org/motion/-/motion-13.4.6.tgz",
"integrity": "sha512-zDXi6lIdyhda6wZlTsv9QgT2ed6UExAfRegtl/akQmZb5iaeLlArXr9Gmo/hx3iTx2y4R7tmdb9k9koFQmV+NQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^13.4.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "13.5.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.5.1.tgz",
"integrity": "sha512-/7CVCD20Mz1KobktorpdgvowV0V41J7UxqE+qWS3GkSJqrRr5ft+2RloOYtEKv+wvBp3JaSFBclhxt4ja8ovHg==",
"license": "MIT",
"dependencies": {
"motion-utils": "13.5.0"
}
},
"node_modules/motion-utils": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.5.0.tgz",
"integrity": "sha512-Hqe/sqjIKliPZg8sVjWAkdySDPJNIeKH+euVkDOG8ri3DUUU7ZjndmHsGqEPz2iHpiyVaV5I22iguCMHU2he+Q==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/n8ao": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-2.0.1.tgz",
"integrity": "sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
"three": ">=0.137"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -9837,6 +10309,21 @@
"node": ">=4"
}
},
"node_modules/postprocessing": {
"version": "6.39.5",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.5.tgz",
"integrity": "sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==",
"license": "Zlib",
"peerDependencies": {
"three": ">= 0.168.0 < 0.187.0"
}
},
"node_modules/potpack": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
@@ -9912,6 +10399,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/promise-worker-transferable/node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -10085,6 +10588,21 @@
"node": ">=0.10.0"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
"integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.13",
"react-dom": ">=16.13"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/recast": {
"version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
@@ -10895,6 +11413,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/stats-gl": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
"license": "MIT",
"dependencies": {
"@types/three": "*",
"three": "^0.170.0"
},
"peerDependencies": {
"@types/three": "*",
"three": "*"
}
},
"node_modules/stats-gl/node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/stats.js": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -11238,6 +11782,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/suspend-react": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
"integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -11302,6 +11855,44 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": {
"version": "0.186.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
"integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
"license": "MIT",
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/three-stdlib": {
"version": "2.36.1",
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
"license": "MIT",
"dependencies": {
"@types/draco3d": "^1.4.0",
"@types/offscreencanvas": "^2019.6.4",
"@types/webxr": "^0.5.2",
"draco3d": "^1.4.1",
"fflate": "^0.6.9",
"potpack": "^1.0.1"
},
"peerDependencies": {
"three": ">=0.128.0"
}
},
"node_modules/three-stdlib/node_modules/fflate": {
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz",
"integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -11450,6 +12041,36 @@
"node": ">=20"
}
},
"node_modules/troika-three-text": {
"version": "0.52.5",
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz",
"integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==",
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.2",
"troika-three-utils": "^0.52.5",
"troika-worker-utils": "^0.52.0",
"webgl-sdf-generator": "1.1.1"
},
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-three-utils": {
"version": "0.52.5",
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz",
"integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.125.0"
}
},
"node_modules/troika-worker-utils": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -11505,6 +12126,43 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-rat": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
"license": "MIT",
"dependencies": {
"zustand": "^4.3.2"
}
},
"node_modules/tunnel-rat/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
@@ -11832,6 +12490,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utility-types": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
@@ -12072,6 +12739,17 @@
"node": ">=18"
}
},
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
},
"node_modules/webgl-sdf-generator": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
@@ -12365,6 +13043,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+8
View File
@@ -13,23 +13,30 @@
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@react-three/drei": "10.7.9",
"@react-three/fiber": "9.8.1",
"@react-three/postprocessing": "3.1.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"motion": "13.4.6",
"next": "16.3.1",
"next-themes": "^0.4.6",
"postprocessing": "6.39.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-leaflet": "^5.0.0",
"shadcn": "^4.18.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"three": "0.186.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@react-three/test-renderer": "9.1.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
@@ -38,6 +45,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
@@ -34,6 +34,7 @@ const feeder: HivePartTypeDto = {
widthMm: 480,
heightMm: 200,
frameSlots: null,
slotWidth: null,
boxDepth: null,
stateSchema: [
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
@@ -22,6 +22,7 @@ const feeder: HivePartTypeDto = {
widthMm: 480,
heightMm: 200,
frameSlots: null,
slotWidth: null,
boxDepth: null,
stateSchema: [
{
@@ -62,6 +63,19 @@ async function pickBox() {
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
}
async function pickFrameFeeder() {
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
await userEvent.click(await screen.findByRole("option", { name: "Futtertasche" }));
}
const pocket: HivePartTypeDto = {
...feeder,
id: "pt2",
kind: "FRAME_FEEDER",
name: "Futtertasche",
slotWidth: 2,
};
describe("PartTypeDialog", () => {
beforeEach(() => {
vi.mocked(createHivePartType).mockReset();
@@ -247,4 +261,60 @@ describe("PartTypeDialog", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("creates a frame feeder with the number of frame slots it covers", async () => {
renderDialog();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await typeInto(/^Belegte Rähmchenplätze/, "2");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", {
kind: "FRAME_FEEDER",
name: "Futtertasche",
lengthMm: 477,
widthMm: 70,
heightMm: 220,
slotWidth: 2,
}),
);
});
it("requires the covered slots of a frame feeder and shows none for other kinds", async () => {
renderDialog();
expect(screen.queryByLabelText(/^Belegte Rähmchenplätze/)).toBeNull();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/belegten Rähmchenplätze/);
expect(createHivePartType).not.toHaveBeenCalled();
});
it("caps the covered slots at the server limit of 5", async () => {
renderDialog();
await typeInto(/^Name/, "Futtertasche");
await pickFrameFeeder();
await typeInto(/^Länge/, "477");
await typeInto(/^Breite/, "70");
await typeInto(/^Höhe/, "220");
await typeInto(/^Belegte Rähmchenplätze/, "9");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ slotWidth: 5 })),
);
});
it("edit: sends only a changed slot width", async () => {
renderDialog(pocket);
expect(screen.getByLabelText(/^Belegte Rähmchenplätze/)).toHaveValue("2");
await typeInto(/^Belegte Rähmchenplätze/, "3");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { slotWidth: 3 }));
});
});
@@ -25,6 +25,7 @@ import {
import {
MAX_DIMENSION_MM,
MAX_FRAME_SLOTS,
MAX_SLOT_WIDTH,
draftProblem,
emptyDraft,
fieldFromDraft,
@@ -59,6 +60,7 @@ interface FormValues {
heightMm: string;
boxDepth: HiveBoxDepth;
frameSlots: string;
slotWidth: string;
}
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
@@ -71,6 +73,7 @@ function initialValues(partType: HivePartTypeDto | undefined): FormValues {
heightMm: partType ? String(partType.heightMm) : "",
boxDepth: partType?.boxDepth ?? "FULL",
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
slotWidth: partType?.slotWidth == null ? "" : String(partType.slotWidth),
};
}
@@ -88,6 +91,9 @@ function valuesProblem(values: FormValues): string | null {
if (values.kind === "BOX" && values.frameSlots === "") {
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
}
if (values.kind === "FRAME_FEEDER" && values.slotWidth === "") {
return "Eine Futtertasche braucht die Zahl der belegten Rähmchenplätze.";
}
return null;
}
@@ -109,6 +115,9 @@ function patchFrom(
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
}
if (partType.kind === "FRAME_FEEDER" && Number(values.slotWidth) !== partType.slotWidth) {
patch.slotWidth = Number(values.slotWidth);
}
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
return patch;
}
@@ -124,6 +133,7 @@ function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTyp
widthMm: Number(values.widthMm),
heightMm: Number(values.heightMm),
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
...(kind === "FRAME_FEEDER" ? { slotWidth: Number(values.slotWidth) } : {}),
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
};
}
@@ -269,6 +279,23 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
</Field>
</>
) : null}
{values.kind === "FRAME_FEEDER" ? (
<Field label="Belegte Rähmchenplätze" required hint="So viele Plätze nimmt die Tasche in der Zarge ein.">
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.slotWidth}
onChange={(slotWidth) => setValue("slotWidth", slotWidth)}
min={1}
max={MAX_SLOT_WIDTH}
unitLabel="Plätze"
decrementLabel="Belegte Rähmchenplätze verringern"
incrementLabel="Belegte Rähmchenplätze erhöhen"
/>
)}
</Field>
) : null}
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
<h3 id="state-fields-heading" className="text-sm font-medium">
@@ -280,7 +307,7 @@ function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
draft={newField}
onChange={setNewField}
onDiscard={() => setNewField(null)}
allowFeedType={values.kind === "FEEDER"}
allowFeedType={values.kind === "FEEDER" || values.kind === "FRAME_FEEDER"}
/>
) : (
<Button
@@ -65,7 +65,7 @@ export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }:
<TableCell className="whitespace-nowrap">
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
</TableCell>
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
<TableCell>{partType.frameSlots ?? (partType.slotWidth ? `belegt ${partType.slotWidth}` : "–")}</TableCell>
<TableCell className="min-w-48 whitespace-normal">
{describeStateFields(partType.stateSchema)}
</TableCell>
+4 -4
View File
@@ -134,8 +134,8 @@
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
@@ -194,8 +194,8 @@
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
--holo-bg: oklch(0.18 0.03 250);
--holo-edge: oklch(0.78 0.14 220);
--holo-edge-strong: oklch(0.88 0.12 215);
--holo-edge: oklch(0.72 0.15 243);
--holo-edge-strong: oklch(0.87 0.1 238);
--holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230);
@@ -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">
<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} />
</div>
</CardContent>
</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,42 +247,101 @@ 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="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}
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)}
{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}`}
>
Zurück
</Button>
<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
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
? "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="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
<div className="min-h-0 flex-1 overflow-y-auto p-2" data-testid="hive-outline-scroll">
<HiveOutline
ref={outlineRef}
state={shownState}
@@ -270,42 +354,8 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
</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}
</div>
<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],
@@ -24,6 +24,8 @@ export interface HivePartTypeInput {
heightMm: number;
boxDepth?: HiveBoxDepth;
frameSlots?: number;
/** Only FRAME_FEEDER: frame slots the pocket covers (1-5). */
slotWidth?: number;
stateSchema?: StateField[];
}
@@ -8,6 +8,8 @@ import type { StateField } from "./types";
export const MAX_DIMENSION_MM = 5000;
/** Upper bound of `frameSlots` in the backend DTO. */
export const MAX_FRAME_SLOTS = 20;
/** Upper bound of `slotWidth` (frame feeder) in the backend DTO. */
export const MAX_SLOT_WIDTH = 5;
export const MAX_KEY_LENGTH = 40;
export const MAX_FIELD_LABEL_LENGTH = 60;
export const MAX_UNIT_LENGTH = 10;
+1
View File
@@ -20,6 +20,7 @@ export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
widthMm: row.widthMm,
heightMm: row.heightMm,
frameSlots: row.frameSlots,
slotWidth: row.slotWidth ?? null,
stateSchema: row.stateSchema,
active: row.active,
}));
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
import { frames, inlineCatalog, part } from "./test-utils";
const catalog = inlineCatalog();
const boxType = catalog.partTypes.get("box")!;
const feederType = catalog.partTypes.get("frame-feeder")!;
describe("feeder slots", () => {
it("reads the covered range from slot and slotWidth", () => {
expect(feederRange(part("p", "frame-feeder", { slot: 3 }), feederType)).toEqual({ from: 3, to: 4 });
expect(feederRange(part("p", "frame-feeder"), feederType)).toBeNull();
expect(feederRange(part("e", "evaporator", { slot: 1 }), catalog.partTypes.get("evaporator"))).toBeNull();
});
it("offers every start in an empty box", () => {
const box = part("b", "box", { frames: frames(10) });
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("leaves out starts that overlap a pocket", () => {
const box = part("b", "box", { frames: frames(10), inserts: [part("p", "frame-feeder", { slot: 4 })] });
expect(pocketRanges(box, catalog)).toEqual([{ from: 4, to: 5 }]);
expect(freeFeederStarts(box, boxType, feederType, catalog)).toEqual([1, 2, 6, 7, 8, 9]);
});
it("offers nothing in a box without slots", () => {
const empty = { ...boxType, frameSlots: 0 };
expect(freeFeederStarts(part("b", "box"), empty, feederType, catalog)).toEqual([]);
});
it("words a range", () => {
expect(rangeText({ from: 1, to: 2 })).toBe("1–2");
expect(rangeText({ from: 3, to: 3 })).toBe("3");
});
});
@@ -0,0 +1,50 @@
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
// RULE_FEEDER_SLOT.
export interface SlotRange {
from: number;
to: number;
}
/** Slots a frame feeder insert covers; null without a usable slot or width. */
export function feederRange(insert: HivePart, type: CatalogPartType | undefined): SlotRange | null {
if (type?.kind !== "FRAME_FEEDER") return null;
const width = type.slotWidth;
if (typeof insert.slot !== "number" || !width) return null;
return { from: insert.slot, to: insert.slot + width - 1 };
}
/** Ranges of the frame feeders already hanging in a box. */
export function pocketRanges(box: HivePart, catalog: HiveCatalog): SlotRange[] {
return (box.inserts ?? []).flatMap((insert) => {
const range = feederRange(insert, catalog.partTypes.get(insert.partTypeId));
return range ? [range] : [];
});
}
/** First slots where a new frame feeder of `feederType` still fits (inside the box, no overlap). */
export function freeFeederStarts(
box: HivePart,
boxType: CatalogPartType,
feederType: CatalogPartType,
catalog: HiveCatalog,
): number[] {
const width = feederType.slotWidth ?? 0;
const slots = boxType.frameSlots ?? 0;
if (width < 1) return [];
const taken = pocketRanges(box, catalog);
const starts: number[] = [];
for (let from = 1; from + width - 1 <= slots; from++) {
const to = from + width - 1;
if (!taken.some((range) => from <= range.to && to >= range.from)) starts.push(from);
}
return starts;
}
/** „1–2“ for a range, „3“ for a single slot. */
export function rangeText(range: SlotRange): string {
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
}
@@ -114,4 +114,40 @@ describe("hiveFollowUps", () => {
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
]);
});
describe("frame feeder", () => {
function withPocket(syrup?: number): HiveState {
const state = templateState();
const frames = state.stack[1].frames!;
frames[0] = { slot: 1, type: "NONE" };
frames[1] = { slot: 2, type: "NONE" };
state.stack[1].inserts = [
{ ...part("ff", "frame-feeder", { slot: 1 }), state: syrup === undefined ? {} : { syrupLiters: syrup } },
];
return state;
}
it("suggests a feeding when the pocket level rises", () => {
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 1.5 }];
expect(run(withPocket(), ops)).toEqual([
{ kind: "feeding", amount: 1.5, unit: "l", feedType: "SUGAR_WATER" },
]);
});
it("suggests nothing when the pocket level falls", () => {
const ops: HiveOp[] = [{ op: "setState", id: "ff", key: "syrupLiters", value: 0.5 }];
expect(run(withPocket(1.5), ops)).toEqual([]);
});
it("suggests the filled amount for a pocket added already filled", () => {
const before = templateState();
const frames = before.stack[1].frames!;
frames[0] = { slot: 1, type: "NONE" };
frames[1] = { slot: 2, type: "NONE" };
const filled = { ...part("ff", "frame-feeder", { slot: 1 }), state: { syrupLiters: 1 } };
expect(run(before, [{ op: "addInsert", hostId: "b", part: filled }])).toEqual([
{ kind: "feeding", amount: 1, unit: "l", feedType: "SUGAR_WATER" },
]);
});
});
});
+5 -2
View File
@@ -10,6 +10,9 @@ export type FollowUp =
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
const ACID_KEY = "acidMl";
/** Part kinds that hold liquid or paste feed (stack feeder and frame feeder pocket). */
const isFeedKind = (kind: string | undefined): boolean => kind === "FEEDER" || kind === "FRAME_FEEDER";
const ROUNDING = 1000;
function findPart(state: HiveState, id: string): HivePart | undefined {
@@ -89,13 +92,13 @@ export function hiveFollowUps(
const was = findPart(before, op.id);
const kind = typeOf(now, catalog)?.kind;
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
else if (now && isFeedKind(kind)) followUps.push(feedingForPart(was, now, op.key, catalog));
} else if (op.op === "addInsert" || op.op === "addPart") {
const now = findPart(after, op.part.id);
const type = typeOf(now, catalog);
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
followUps.push(treatmentFor(findPart(before, now.id), now));
} else if (now && type?.kind === "FEEDER") {
} else if (now && type && isFeedKind(type.kind)) {
for (const field of type.stateSchema) {
if (field.kind === "level" && once(now.id, field.key)) {
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
+1
View File
@@ -27,4 +27,5 @@ export const PART_KIND_LABELS: Record<HivePartKind, string> = {
ROOF: "Dach",
FLOOR_INSERT: "Bausperre",
EVAPORATOR: "Verdunster",
FRAME_FEEDER: "Futtertasche",
};
+28 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { hiveLayout } from "./layout";
import { FLOOR_GRID_DEPTH, hiveLayout } from "./layout";
import { inlineCatalog, part, templateState } from "./test-utils";
describe("hiveLayout", () => {
@@ -49,14 +49,16 @@ describe("hiveLayout", () => {
});
});
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
it("lays the floor insert on the floor's grid (40 mm below the floor top) and the evaporator on the box bottom", () => {
const state = templateState();
state.stack[0].inserts = [part("fi", "floor-insert")];
state.stack[1].inserts = [part("ev", "evaporator")];
const layout = hiveLayout(state, catalog);
const insert = layout.inserts.find((i) => i.id === "fi")!;
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
expect(insert.center[1]).toBeCloseTo(0.05, 9);
// Floor top 1.2, grid 40 mm (0.4 units) below it, Bausperre 5 mm thick lying on the grid.
expect(insert.center[1]).toBeCloseTo(1.2 - FLOOR_GRID_DEPTH + 0.025, 9);
expect(FLOOR_GRID_DEPTH).toBeCloseTo(0.4, 9);
expect(insert.kind).toBe("FLOOR_INSERT");
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
expect(evaporator.stackIndex).toBe(1);
@@ -66,4 +68,27 @@ describe("hiveLayout", () => {
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
});
it("lays a frame feeder over its slots with the frame height", () => {
const state = templateState();
const frames = state.stack[1].frames!;
frames[2] = { slot: 3, type: "NONE" };
frames[3] = { slot: 4, type: "NONE" };
state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 3 })];
const layout = hiveLayout(state, catalog);
const pocket = layout.inserts.find((i) => i.id === "ff")!;
const inner = 5.25 - 0.4;
const pitch = inner / 10;
const box = layout.parts[1];
expect(pocket.kind).toBe("FRAME_FEEDER");
expect(pocket.stackIndex).toBe(1);
expect(pocket.size[0]).toBeCloseTo(2 * pitch, 9);
expect(pocket.size[1]).toBeCloseTo(2.2, 9);
expect(pocket.size[2]).toBeCloseTo(4.3 - 0.4, 9);
expect(pocket.center[0]).toBeCloseTo(-inner / 2 + pitch * 3, 9);
expect(pocket.center[1]).toBeCloseTo(box.center[1], 9);
// The pocket sits exactly where frames 3 and 4 would hang.
const covered = layout.frames.filter((f) => f.slot === 3 || f.slot === 4).map((f) => f.center[0]);
expect(pocket.center[0]).toBeCloseTo((covered[0] + covered[1]) / 2, 9);
});
});
+45 -17
View File
@@ -37,10 +37,14 @@ const ROOF_OVERLAP_MM = 35;
const BOX_WALL_MM = 20;
const FRAME_THICKNESS_MM = 25;
const FRAME_HEIGHT_MARGIN_MM = 10;
const FLOOR_INSERT_LIFT_MM = 5;
/** The stainless grid of the floor (Liebig Wanderboden) lies this far below its top edge. */
const FLOOR_GRID_DEPTH_MM = 40;
const mm = (value: number): number => value / UNIT_MM;
/** Depth of the floor's grid below the floor's top edge, in layout units (shared with the 3D floor). */
export const FLOOR_GRID_DEPTH = mm(FLOOR_GRID_DEPTH_MM);
function sizeOf(type: CatalogPartType): Vec3 {
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
}
@@ -52,11 +56,11 @@ function insertBox(
): PartBox {
const size = sizeOf(type);
const hostBottom = host.center[1] - host.size[1] / 2;
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
// bottom + 5 mm, y is always a centre); every other insert
// (the EVAPORATOR) stands on the bottom of the box it sits in.
const hostTop = host.center[1] + host.size[1] / 2;
// FLOOR_INSERT (Bausperre) lies on the floor's grid near its top edge; every
// other insert (the EVAPORATOR) stands on the bottom of the box it sits in.
const centerY =
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
type.kind === "FLOOR_INSERT" ? hostTop - FLOOR_GRID_DEPTH + size[1] / 2 : hostBottom + size[1] / 2;
return {
id: insert.id,
partTypeId: insert.partTypeId,
@@ -67,22 +71,42 @@ function insertBox(
};
}
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
const frames = part.frames ?? [];
if (frames.length === 0) return [];
/** Frame grid of a box: x of the first slot's left edge, slot pitch, frame size. */
function frameGrid(part: HivePart, type: CatalogPartType, box: PartBox): { left: number; pitch: number; size: Vec3 } | null {
const count = part.frames?.length ?? 0;
if (count === 0) return null;
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
const pitch = inner / frames.length;
const size: Vec3 = [
mm(FRAME_THICKNESS_MM),
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
box.size[2] - 2 * mm(BOX_WALL_MM),
];
return {
left: -inner / 2,
pitch: inner / count,
size: [mm(FRAME_THICKNESS_MM), mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM), box.size[2] - 2 * mm(BOX_WALL_MM)],
};
}
/** A frame feeder hangs where its slots' frames would: width = slotWidth x pitch, frame height. */
function feederBox(insert: HivePart, type: CatalogPartType, host: PartBox, grid: ReturnType<typeof frameGrid>): PartBox | null {
if (!grid || typeof insert.slot !== "number" || !type.slotWidth) return null;
const width = grid.pitch * type.slotWidth;
return {
id: insert.id,
partTypeId: insert.partTypeId,
kind: type.kind,
center: [grid.left + grid.pitch * (insert.slot - 1) + width / 2, host.center[1], 0],
size: [width, grid.size[1], grid.size[2]],
stackIndex: host.stackIndex,
};
}
function frameBoxes(part: HivePart, box: PartBox, grid: ReturnType<typeof frameGrid>): FrameBox[] {
const frames = part.frames ?? [];
if (!grid) return [];
const { left, pitch, size } = grid;
return frames.map((frame, index) => ({
boxId: part.id,
slot: frame.slot,
type: frame.type,
...(frame.name !== undefined ? { name: frame.name } : {}),
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
center: [left + pitch * (index + 0.5), box.center[1], 0],
size,
}));
}
@@ -116,11 +140,15 @@ export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
width = Math.max(width, size[0]);
depth = Math.max(depth, size[2]);
const grid = type.kind === "BOX" ? frameGrid(part, type, box) : null;
for (const insert of part.inserts ?? []) {
const insertType = catalog.partTypes.get(insert.partTypeId);
if (insertType) inserts.push(insertBox(insert, insertType, box));
if (!insertType) continue;
const placed =
insertType.kind === "FRAME_FEEDER" ? feederBox(insert, insertType, box, grid) : insertBox(insert, insertType, box);
if (placed) inserts.push(placed);
}
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
if (type.kind === "BOX") frames.push(...frameBoxes(part, box, grid));
});
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
@@ -8,6 +8,7 @@ const TEXTS: Record<string, string> = {
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
RULE_FEEDER_SLOT: "Die Futtertasche passt dort nicht hin.",
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
@@ -66,6 +66,7 @@ function type(
widthMm,
heightMm,
frameSlots: null,
slotWidth: null,
stateSchema: noState,
active: true,
...extra,
@@ -93,6 +94,12 @@ export function inlineCatalog(): HiveCatalog {
}),
type("insulation", "INSULATION", 465, 430, 20),
type("roof", "ROOF", 525, 430, 60),
type("frame-feeder", "FRAME_FEEDER", 477, 70, 220, {
slotWidth: 2,
stateSchema: [
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 2, step: 0.25, feedType: "SUGAR_WATER" },
],
}),
type("evaporator", "EVAPORATOR", 200, 150, 120, {
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
}),
+6 -1
View File
@@ -14,7 +14,8 @@ export type HivePartKind =
| "INSULATION"
| "ROOF"
| "FLOOR_INSERT"
| "EVAPORATOR";
| "EVAPORATOR"
| "FRAME_FEEDER";
export type HiveBoxDepth = "FULL" | "SHALLOW";
@@ -61,6 +62,8 @@ export interface HivePart {
state: Record<string, number | string>;
inserts?: HivePart[];
frames?: HiveFrame[];
/** Only on FRAME_FEEDER inserts: first frame slot (1-based) the pocket covers. */
slot?: number;
}
export interface HiveState {
@@ -112,6 +115,8 @@ export interface CatalogPartType {
widthMm: number;
heightMm: number;
frameSlots: number | null;
/** Only FRAME_FEEDER: number of consecutive frame slots the pocket covers. */
slotWidth: number | null;
stateSchema: StateField[];
active: boolean;
}
+86 -12
View File
@@ -13,10 +13,12 @@ import {
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
// (`.length`).
// same order; then per insert RULE_INSERT_HOST, nested inserts and `slot` /
// RULE_FEEDER_SLOT; after all inserts of a box the FRAME_FEEDER overlap check,
// also RULE_FEEDER_SLOT), then FLOOR, then ROOF. Text lengths are UTF-16 code
// units (`.length`).
const ROOT_KEYS = ["schemaVersion", "stack"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames", "slot"];
const FRAME_KEYS = ["slot", "type", "name"];
type Obj = Record<string, unknown>;
@@ -144,11 +146,71 @@ function validatePartCommon(
return { part, type };
}
function allowedInsertKind(hostType: CatalogPartType): string | null {
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
if (hostType.kind === "BOX") return "EVAPORATOR";
const INSERT_KINDS: Partial<Record<string, string[]>> = {
FLOOR: ["FLOOR_INSERT"],
BOX: ["EVAPORATOR", "FRAME_FEEDER"],
};
const INSERT_ONLY_KINDS: string[] = ["FLOOR_INSERT", "EVAPORATOR", "FRAME_FEEDER"];
interface SlotRange {
from: number;
to: number;
}
/**
* RULE_FEEDER_SLOT checks for one insert: `slot` only on FRAME_FEEDER, an
* integer inside 1..frameSlots for the covered width, and every covered frame
* NONE. Returns the covered range for the overlap check, or null.
*/
function validateFeederSlot(
insert: Obj,
type: CatalogPartType,
host: Obj,
hostType: CatalogPartType,
where: string,
out: string[],
): SlotRange | null {
if (type.kind !== "FRAME_FEEDER") {
if (insert.slot !== undefined) out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
return null;
}
if (hostType.kind !== "BOX") return null;
const slot = insert.slot;
const width = type.slotWidth;
const slots = hostType.frameSlots ?? 0;
if (typeof slot !== "number" || !Number.isInteger(slot) || slot < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} needs an integer slot of 1 or more`);
return null;
}
if (width === null || width === undefined || !Number.isInteger(width) || width < 1) {
out.push(`RULE_FEEDER_SLOT: ${where} part type has no slot width`);
return null;
}
const range = { from: slot, to: slot + width - 1 };
if (range.to > slots) {
out.push(`RULE_FEEDER_SLOT: ${where} covers slots ${range.from}-${range.to} but the box has ${slots}`);
return null;
}
const frames = Array.isArray(host.frames) ? (host.frames as Obj[]) : [];
for (let at = range.from; at <= range.to; at++) {
if (frames[at - 1]?.type !== "NONE") {
out.push(`RULE_FEEDER_SLOT: ${where} covers frame ${at} which is not NONE`);
return null;
}
}
return range;
}
function validateFeederOverlap(ranges: SlotRange[], where: string, out: string[]): void {
const sorted = [...ranges].sort((a, b) => a.from - b.from);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].from <= sorted[i - 1].to) {
out.push(`RULE_FEEDER_SLOT: ${where} has overlapping frame feeders`);
return;
}
}
}
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
const { out } = ctx;
@@ -158,25 +220,34 @@ function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ct
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
return;
}
const allowed = allowedInsertKind(hostType);
const allowed = INSERT_KINDS[hostType.kind] ?? null;
if (inserts.length > 0 && allowed === null) {
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
}
if (allowed !== null && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
if (hostType.kind === "FLOOR" && inserts.length > 1) {
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one FLOOR_INSERT`);
}
const ranges: SlotRange[] = [];
let evaporators = 0;
inserts.forEach((insert: unknown, index) => {
const here = `${where}.inserts[${index}]`;
const checked = validatePartCommon(insert, here, ctx);
if (!checked) return;
if (allowed !== null && checked.type.kind !== allowed) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
if (checked.type.kind === "EVAPORATOR") {
evaporators += 1;
if (evaporators > 1) out.push(`RULE_INSERT_LIMIT: ${where} holds at most one EVAPORATOR`);
}
if (allowed !== null && !allowed.includes(checked.type.kind)) {
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed.join(" or ")}`);
}
const nested = checked.part.inserts;
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
}
const range = validateFeederSlot(checked.part, checked.type, host, hostType, here, out);
if (range) ranges.push(range);
});
validateFeederOverlap(ranges, where, out);
}
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
@@ -207,9 +278,12 @@ export function validateHiveState(state: unknown, catalog: HiveCatalog): string[
const checked = validatePartCommon(entry, where, ctx);
types.push(checked?.type ?? null);
if (!checked) return;
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
if (INSERT_ONLY_KINDS.includes(checked.type.kind)) {
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
}
if (checked.part.slot !== undefined) {
out.push(`RULE_FEEDER_SLOT: ${where} has a slot but is no frame feeder`);
}
validateInserts(checked.part, checked.type, where, ctx);
});
validateStackRules(types, out);
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { canUseWebGL, resetWebGLProbe, shouldUse3D } from "./webgl";
const base = { webgl: true, motion: "full", cores: 8, forcedColors: false } as const;
describe("shouldUse3D", () => {
it("is true with WebGL and a normal setup", () => {
expect(shouldUse3D(base)).toBe(true);
});
it("is false without WebGL", () => {
expect(shouldUse3D({ ...base, webgl: false })).toBe(false);
});
it("is false with forced colors", () => {
expect(shouldUse3D({ ...base, forcedColors: true })).toBe(false);
});
it("is false with motion off on a weak device (2 cores or fewer)", () => {
expect(shouldUse3D({ ...base, motion: "off", cores: 2 })).toBe(false);
expect(shouldUse3D({ ...base, motion: "off", cores: 1 })).toBe(false);
});
it("keeps 3D with motion off on a stronger device", () => {
expect(shouldUse3D({ ...base, motion: "off", cores: 4 })).toBe(true);
});
it("keeps 3D on a weak device when motion is not off", () => {
expect(shouldUse3D({ ...base, motion: "subtle", cores: 2 })).toBe(true);
});
});
describe("canUseWebGL", () => {
afterEach(() => {
vi.restoreAllMocks();
resetWebGLProbe();
});
it("is false when no context can be created (jsdom)", () => {
expect(canUseWebGL()).toBe(false);
});
it("is true when a context is available, and probes only once", () => {
const getContext = vi
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as RenderingContext);
expect(canUseWebGL()).toBe(true);
expect(canUseWebGL()).toBe(true);
expect(getContext).toHaveBeenCalledTimes(1);
});
it("is false when creating the context throws", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("blocked");
});
expect(canUseWebGL()).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { MotionLevel } from "@/lib/motion/motion-preference";
/** At or below this many cores, motion `off` keeps the card on the SVG (spec 9.7). */
const WEAK_DEVICE_CORES = 2;
let probed: boolean | undefined;
/** Whether a WebGL context can be created here. Probed once, then cached. */
export function canUseWebGL(): boolean {
if (probed !== undefined) return probed;
if (typeof document === "undefined") return false;
try {
const canvas = document.createElement("canvas");
probed = Boolean(canvas.getContext("webgl2") ?? canvas.getContext("webgl"));
} catch {
probed = false;
}
return probed;
}
/** Test hook: forget the cached probe result. */
export function resetWebGLProbe(): void {
probed = undefined;
}
export interface Use3DInput {
webgl: boolean;
motion: MotionLevel;
cores: number;
forcedColors: boolean;
}
/** The card shows the 3D scene only when all of this holds; otherwise it stays on the SVG. */
export function shouldUse3D({ webgl, motion, cores, forcedColors }: Use3DInput): boolean {
if (!webgl || forcedColors) return false;
return !(motion === "off" && cores <= WEAK_DEVICE_CORES);
}
@@ -93,6 +93,7 @@ Messbar fertig ist dieses Teilprojekt, wenn:
| `ROOF` | Stapel | Platte mit Rand, über das Teil darunter gestülpt | Nur ganz oben. |
| `FLOOR_INSERT` | Einlage im Boden | Gitterfläche im Boden | Bausperre. Höchstens eine je Boden. |
| `EVAPORATOR` | Einsatz in einer Zarge | Flasche mit Pegel auf Docht-Platte | Ameisensäure-Verdunster. Liegt auf den Rähmchen der Zarge darunter, darum meist in einer Leerzarge. Höchstens einer je Zarge. |
| `FRAME_FEEDER` | Einsatz in einer Zarge, an Rähmchen-Plätzen | Futtertasche mit Pegel | Hängt anstelle von Rähmchen in der Zarge und belegt `slotWidth` Plätze (Teile-Art, Futtertasche doppelte Wabenbreite = 2) ab Platz `slot` (Feld am Einsatz). Die belegten Plätze müssen im `frames`-Array der Zarge den Typ `NONE` haben. Mehrere je Zarge erlaubt, ohne Überlappung. Zustandsfeld Flüssigfutter in l. |
Alle anderen Reihenfolgen sind erlaubt. Ungewöhnliche Folgen (Absperrgitter direkt auf dem Boden, Dämmung unter der Zarge) sind Hinweise in der Oberfläche, keine Sperre, weil Imker umbauen, wie es die Lage verlangt.
@@ -135,6 +136,7 @@ Maße aus den Holtermann-Produktseiten (abgerufen 2026-10-15). Alle Werte sind K
| Styropor-Einlage (selbst gebaut) | INSULATION | 465 × 365 × 20 | — |
| Edelstahldeckel konisch Liebig | ROOF | 525 × 430 × 60 (Innenmaß) | — |
| Nassenheider Verdunster Professional | EVAPORATOR | 200 × 140 × 120 | Ameisensäure 0–290 ml |
| Holz-Futtertasche Zander, doppelte Wabenbreite (Inhaber 2026-10-16; Vorlage Holtermann Art. 7142 in Normalmaß: 370 × 221 mm, Oberträger 394 mm) | FRAME_FEEDER, `slotWidth` 2 | 477 × 70 × 220 (Zander-Annahme) | Flüssigfutter 0–2 l, Schritt 0,25 (Annahme, Holtermann nennt kein Volumen) |
Die Startdaten kommen per Migration mit **festen Ids** (`hive-system-zander-liebig-10`, `hive-part-zander-liebig-10-floor`, …), damit die Vorlage stabile Verweise hat und eine Wiederherstellung aus dem Backup in eine frische Installation keine doppelten Namen erzeugt (Abschnitt 5.1).
@@ -217,7 +219,8 @@ model HiveChange {
@@index([colonyId, occurredOn])
}
enum HivePartKind { FLOOR BOX DIVIDER_BOARD QUEEN_EXCLUDER FEEDER COVER_SHEET PROPOLIS_SCREEN INNER_LID INSULATION ROOF FLOOR_INSERT EVAPORATOR }
enum HivePartKind { FLOOR BOX DIVIDER_BOARD QUEEN_EXCLUDER FEEDER COVER_SHEET PROPOLIS_SCREEN INNER_LID INSULATION ROOF FLOOR_INSERT EVAPORATOR FRAME_FEEDER }
// Nachtrag 2026-10-16: HivePartType.slotWidth Int? (nur FRAME_FEEDER), HivePart.slot (nur FRAME_FEEDER-Einsatz)
enum HiveBoxDepth { FULL SHALLOW }
enum HiveChangeSource { INIT CARD EDITOR INSPECTION AI_SUGGESTION }
```
@@ -378,7 +381,8 @@ Der Zustand der Ansicht (berührtes Teil, offene Zarge, berührtes Rähmchen) li
Inhaber-Entscheidung 2026-10-16 nach Bild-Entwürfen (`.superpowers/mockups/`, Richtung D/E/F, Kalibrierung G/H/I): Die Karte ist eine **randlose, tiefe Holo-Bühne** im warmen App-Rahmen. Qualitätsanspruch: wirkt wie ein Produkt-Render aus einer Keynote, nicht wie eine Linienzeichnung.
- **Bühne:** füllt die ganze Karte (Titel „Beute“, Stand-Zeile und Knöpfe liegen als Milchglas-Elemente über der Bühne). Tiefes Mitternachtsblau mit Vignette, ein dezentes Waben-Raster als spiegelnder Boden, wenige langsam treibende Lichtpartikel, weiche Lichtquelle von unten.
- **Bühne:** füllt die ganze Karte (Titel „Beute“, Stand-Zeile und Knöpfe liegen als Milchglas-Elemente über der Bühne). Tiefes Mitternachtsblau mit Vignette, ein dezentes Waben-Raster als spiegelnder Boden, wenige langsam treibende Lichtpartikel. Licht nur kühl (Blau/Cyan/Weiß), **kein goldenes oder warmes Leuchten** in der Ruhe-Ansicht (Inhaber 2026-10-16); warme Farben nur als Inhalt (Futter, Rähmchen-Look).
- **Fuß der Beute ist der Liebig-Wanderboden** (Inhaber 2026-10-16): flacher Kasten 525 × 430 × 120 mm mit Edelstahl-Gitter oben, darunter von hinten eingeschobene Varroa-Schublade, Kontrollklappe, Flugloch-Schieber vorn. Keine Füße, kein Sockel, kein vorstehendes Anflugbrett.
- **Ruhe-Ansicht: alles reines Glas.** Jedes Teil (Dach, Deckel, Dämmung, Folie, Futterzarge, Zargen, Boden, Einlagen) ist klares Glas mit scharfen, blau leuchtenden Kanten (Bloom), leichter Lichtbrechung und Spiegelung. Kein Holz, keine Bienen, keine Farben. Rähmchen sind nur als feine Glas-Umrisse angedeutet, Füllstände nicht sichtbar.
- **Fokus-Ansicht:** Das gewählte Teil zeigt seinen **Inhalt**: Futterzarge mit leuchtendem, leicht wogendem goldenem Flüssigfutter und einem Futterteig-Block mit Etiketten „Flüssigfutter 2,5 l von 3 l“, „Futterteig 1 kg“; Verdunster mit Säurepegel; Boden mit Bausperre bzw. Varroa-Schublade; Deckel und Dach mit ihren Zuständen. Zargen zeigen ihre Rähmchen als klare Glasplatten.
- **Rähmchen-Look:** Ein Rähmchen bekommt seine Art als Farbe **und** Muster erst, wenn es angehoben (Hover/Tippen) oder ausgewählt ist: Brutwabe bernstein mit verdeckelter Brut-Wabenstruktur, Drohnenwabe dunkleres Bernstein mit gewölbten Zellen, Honigwabe gold glänzend, Pollenwabe orange-gefleckt, Futterwabe hellgold, Mittelwand helles Wachs-Raster, Leerwabe leere Zellen, Baurahmen offener Rahmen, Schied massive Glasplatte. Die Art steht zusätzlich als Wort im Etikett (T5: Zustand nie nur über Farbe).