- 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]>
288 lines
9.9 KiB
TypeScript
288 lines
9.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|