Files
ApiCore/ApiCore-Frontend/src/components/hive/hive-svg.tsx
T
Nick SzaunigandClaude Opus 5.5 c608f5d405 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]>
2026-10-16 09:12:03 +02:00

288 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}