"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(); 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, ReactNode> = { BROOD: , DRONE: , HONEY: , POLLEN: , FEED: , FOUNDATION: , EMPTY_COMB: , DRONE_FRAME: , DIVIDER: , }; const PATTERN_TYPES = Object.keys(PATTERNS) as Exclude[]; // Colour comes from the ; 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 ( {name} {faces.map((d_, index) => ( ))} ); } 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 ( {title} ); } 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 ( {frames.length > 0 ? ( {PATTERN_TYPES.map((type) => ( {PATTERNS[type]} ))} ) : null} {layout.parts.map((part) => ( ))} {layout.inserts.map((insert) => ( ))} {frames.map((frame) => ( ))} ); }