Gemeinsame Helfer statt vier Kopien der Teilsuche, zwei Vektor- Subtraktionen und zwei Zufallsgeneratoren; hasPartSheet an einer Stelle; ungenutzte setOpacity und points-Ref entfernt. Verhalten unverändert (1050 Tests). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
|
|
import { findPart } from "@/lib/hive/find-part";
|
|
import type { HiveCatalog, 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;
|
|
}
|
|
|
|
/** 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 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>
|
|
);
|
|
}
|