feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

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 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

@@ -0,0 +1,287 @@
"use client";
import { useId } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels";
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { FrameType } from "@/lib/hive/types";
import { useHiveView } from "./use-hive-view";
// SVG view of the hive: fallback without WebGL, print, and the L2 visual.
// Oblique projection of the shared layout (1 unit = 100 mm, y up). Strokes use
// currentColor so forced-colors and print follow the surrounding text colour.
const SKEW_X = 0.5;
const SKEW_Y = 0.35;
const PADDING = 0.05;
const FRAME_FILL_RATIO = 0.9;
const PATTERN_TILE = 0.16;
const EMPTY_VIEW_BOX = "0 0 1 1";
type Point = [number, number];
export function projectPoint([x, y, z]: Vec3): Point {
return [x + SKEW_X * z, -(y - SKEW_Y * z)];
}
const fmt = (value: number): string => Number(value.toFixed(4)).toString();
function polygon(points: Vec3[]): string {
return (
points
.map((point, index) => {
const [x, y] = projectPoint(point);
return `${index === 0 ? "M" : "L"}${fmt(x)} ${fmt(y)}`;
})
.join(" ") + " Z"
);
}
function corners(box: { center: Vec3; size: Vec3 }): Vec3[] {
const [cx, cy, cz] = box.center;
const [w, h, d] = box.size;
const result: Vec3[] = [];
for (const dx of [-1, 1]) for (const dy of [-1, 1]) for (const dz of [-1, 1]) {
result.push([cx + (dx * w) / 2, cy + (dy * h) / 2, cz + (dz * d) / 2]);
}
return result;
}
function viewBoxOf(layout: HiveLayout): string {
const points = [...layout.parts, ...layout.inserts].flatMap((part) => corners(part).map(projectPoint));
if (points.length === 0) return EMPTY_VIEW_BOX;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const width = Math.max(...xs) - minX;
const height = Math.max(...ys) - minY;
return [minX - PADDING * width, minY - PADDING * height, width * (1 + 2 * PADDING), height * (1 + 2 * PADDING)]
.map(String)
.join(" ");
}
/** "Beute mit 2 Zargen, Boden, Dach": boxes first, then the rest bottom to top. */
function summaryOf(layout: HiveLayout): string {
if (layout.parts.length === 0) return "Beute ohne Teile";
const counts = new Map<string, number>();
for (const part of layout.parts) {
const label = PART_KIND_LABELS[part.kind];
counts.set(label, (counts.get(label) ?? 0) + 1);
}
const boxLabel = PART_KIND_LABELS.BOX;
const labels = [...counts.keys()].sort((a, b) => Number(b === boxLabel) - Number(a === boxLabel));
const items = labels.map((label) => {
const count = counts.get(label) ?? 1;
if (count === 1) return label;
return label === boxLabel ? `${count} Zargen` : `${count}× ${label}`;
});
return `Beute mit ${items.join(", ")}`;
}
function framePitch(frame: FrameBox, all: FrameBox[]): number {
const xs = all.filter((other) => other.boxId === frame.boxId).map((other) => other.center[0]).sort((a, b) => a - b);
const gaps = xs.slice(1).map((x, index) => x - xs[index]);
return gaps.length > 0 ? Math.min(...gaps) : frame.size[0];
}
// ---- Frame patterns: one per type, so the type never rides on colour alone ----
const T = PATTERN_TILE;
const PATTERNS: Record<Exclude<FrameType, "NONE">, ReactNode> = {
BROOD: <circle cx={T / 2} cy={T / 2} r={0.035} className="fill-current stroke-none" />,
DRONE: <circle cx={T / 2} cy={T / 2} r={0.06} className="fill-current stroke-none" />,
HONEY: <path d={`M0 ${T} L${T} 0`} />,
POLLEN: <path d={`M${T / 2} 0 V${T} M0 ${T / 2} H${T}`} />,
FEED: <path d={`M0 ${T / 2} H${T}`} />,
FOUNDATION: <path d={`M${T / 2} 0 V${T}`} />,
EMPTY_COMB: <circle cx={T / 2} cy={T / 2} r={0.05} className="fill-none" />,
DRONE_FRAME: <path d={`M0 0 L${T} ${T} M0 ${T} L${T} 0`} />,
DIVIDER: <rect width={T} height={T} className="fill-current opacity-60 stroke-none" />,
};
const PATTERN_TYPES = Object.keys(PATTERNS) as Exclude<FrameType, "NONE">[];
// Colour comes from the <svg>; only hover changes it, never in print.
const HOVER_COLOR = "data-[hovered=true]:text-holo-edge-strong print:data-[hovered=true]:text-black";
const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]";
function activate(event: KeyboardEvent, action: () => void): void {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
action();
}
export interface HiveSvgProps {
layout: HiveLayout;
onSelectPart?: (id: string) => void;
onSelectFrame?: (boxId: string, slot: number) => void;
/** Display name per part (see partDisplayName); defaults to the kind label. */
partName?: (id: string) => string;
}
interface PartShapeProps {
part: PartBox;
dashed?: boolean;
name: string;
hovered: boolean;
onSelect?: (id: string) => void;
onHover: (id: string | null) => void;
}
function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShapeProps) {
const [cx, cy, cz] = part.center;
const [w, h, d] = part.size;
const [x0, x1, y0, y1, z0, z1] = [cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2, cz - d / 2, cz + d / 2];
const faces = [
polygon([[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]]),
polygon([[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]]),
polygon([[x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0]]),
];
const interactive = onSelect
? {
role: "button" as const,
tabIndex: -1,
onClick: () => onSelect(part.id),
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)),
onPointerEnter: () => onHover(part.id),
onPointerLeave: () => onHover(null),
}
: {};
return (
<g
data-part-id={part.id}
data-hovered={hovered ? "true" : undefined}
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
{...interactive}
>
<title>{name}</title>
{faces.map((d_, index) => (
<path
key={index}
d={d_}
className={`${edge} fill-holo-fill ${dashed ? "[stroke-dasharray:4_3]" : ""}`}
strokeWidth={hovered ? 2.5 : 1.5}
/>
))}
</g>
);
}
interface FrameShapeProps {
frame: FrameBox;
width: number;
patternPrefix: string;
hovered: boolean;
onSelect?: (boxId: string, slot: number) => void;
onHover: (frame: { boxId: string; slot: number } | null) => void;
}
function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }: FrameShapeProps) {
const [cx, cy, cz] = frame.center;
const [, h, d] = frame.size;
const [x, y] = projectPoint([cx - width / 2, cy + h / 2, cz - d / 2]);
const [x2, y2] = projectPoint([cx + width / 2, cy - h / 2, cz - d / 2]);
const title = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
const ref = { boxId: frame.boxId, slot: frame.slot };
const interactive = onSelect
? {
role: "button" as const,
tabIndex: -1,
onClick: () => onSelect(frame.boxId, frame.slot),
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)),
onPointerEnter: () => onHover(ref),
onPointerLeave: () => onHover(null),
}
: {};
return (
<g
data-frame-box={frame.boxId}
data-frame-slot={frame.slot}
data-frame-type={frame.type}
data-hovered={hovered ? "true" : undefined}
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
{...interactive}
>
<title>{title}</title>
<rect
x={x}
y={y}
width={x2 - x}
height={y2 - y}
fill={frame.type === "NONE" ? "none" : `url(#${patternPrefix}-${frame.type})`}
className={`${edge} ${frame.type === "NONE" ? "[stroke-dasharray:2_3]" : ""}`}
strokeWidth={hovered ? 2.5 : 1}
/>
</g>
);
}
export function HiveSvg({ layout, onSelectPart, onSelectFrame, partName }: HiveSvgProps) {
const view = useHiveView();
const patternPrefix = `hive${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
const nameOf = (part: PartBox): string => partName?.(part.id) ?? PART_KIND_LABELS[part.kind];
const frames = view.openBoxId ? layout.frames.filter((frame) => frame.boxId === view.openBoxId) : [];
const hoveredFrame = view.hoveredFrame;
return (
<svg
role="img"
aria-label={summaryOf(layout)}
viewBox={viewBoxOf(layout)}
preserveAspectRatio="xMidYMid meet"
className="block h-full w-full bg-holo-bg text-holo-edge forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] print:bg-transparent print:text-black"
>
{frames.length > 0 ? (
<defs>
{PATTERN_TYPES.map((type) => (
<pattern
key={type}
id={`${patternPrefix}-${type}`}
width={PATTERN_TILE}
height={PATTERN_TILE}
patternUnits="userSpaceOnUse"
className="fill-none stroke-current"
strokeWidth={0.02}
>
{PATTERNS[type]}
</pattern>
))}
</defs>
) : null}
{layout.parts.map((part) => (
<PartShape
key={part.id}
part={part}
name={nameOf(part)}
hovered={view.hoveredId === part.id}
onSelect={onSelectPart}
onHover={view.setHovered}
/>
))}
{layout.inserts.map((insert) => (
<PartShape
key={insert.id}
part={insert}
dashed
name={nameOf(insert)}
hovered={view.hoveredId === insert.id}
onSelect={onSelectPart}
onHover={view.setHovered}
/>
))}
{frames.map((frame) => (
<FrameShape
key={`${frame.boxId}-${frame.slot}`}
frame={frame}
width={framePitch(frame, frames) * FRAME_FILL_RATIO}
patternPrefix={patternPrefix}
hovered={hoveredFrame?.boxId === frame.boxId && hoveredFrame.slot === frame.slot}
onSelect={onSelectFrame}
onHover={view.setHoveredFrame}
/>
))}
</svg>
);
}