Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
230
apps/web/src/components/DofStatusBar.tsx
Normal file
230
apps/web/src/components/DofStatusBar.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDiagramStore } from "@/store/diagramStore";
|
||||
import { buildDofCoach, type CoachSeverity } from "@/lib/dofCoach";
|
||||
import type { DofBalance } from "@/lib/dofLedger";
|
||||
import type { EntropykNodeData } from "@/lib/configBuilder";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronUp,
|
||||
Equal,
|
||||
Lightbulb,
|
||||
MinusCircle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
function balanceStyle(balance: DofBalance): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
} {
|
||||
switch (balance) {
|
||||
case "balanced":
|
||||
return {
|
||||
bg: "bg-emerald-50",
|
||||
fg: "text-emerald-800",
|
||||
border: "border-emerald-200",
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
case "over-constrained":
|
||||
return {
|
||||
bg: "bg-red-50",
|
||||
fg: "text-red-800",
|
||||
border: "border-red-200",
|
||||
Icon: AlertTriangle,
|
||||
};
|
||||
case "under-constrained":
|
||||
return {
|
||||
bg: "bg-amber-50",
|
||||
fg: "text-amber-900",
|
||||
border: "border-amber-200",
|
||||
Icon: MinusCircle,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-[var(--chrome-2)]",
|
||||
fg: "text-[var(--ink-dim)]",
|
||||
border: "border-[var(--line)]",
|
||||
Icon: Equal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function tipIcon(sev: CoachSeverity) {
|
||||
switch (sev) {
|
||||
case "ok":
|
||||
return <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-emerald-600" />;
|
||||
case "block":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-red-600" />;
|
||||
case "warn":
|
||||
return <AlertTriangle size={12} className="mt-0.5 shrink-0 text-amber-600" />;
|
||||
default:
|
||||
return <Lightbulb size={12} className="mt-0.5 shrink-0 text-[var(--accent)]" />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DofStatusBar() {
|
||||
const nodes = useDiagramStore((s) => s.nodes) as Node<EntropykNodeData>[];
|
||||
const edges = useDiagramStore((s) => s.edges);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<"guide" | "ledger">("guide");
|
||||
|
||||
const coach = useMemo(() => buildDofCoach(nodes, edges), [nodes, edges]);
|
||||
const { ledger } = coach;
|
||||
const style = balanceStyle(ledger.balance);
|
||||
const Icon = style.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t ${style.border} ${style.bg} text-[11px] ${style.fg}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-3 py-1.5 text-left transition-colors hover:brightness-[0.98]"
|
||||
title="Guide de balance DoF — cliquer pour les étapes"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="font-semibold tracking-tight">
|
||||
{ledger.balance === "balanced"
|
||||
? "Balance OK"
|
||||
: ledger.balance === "over-constrained"
|
||||
? "Trop de Fixed"
|
||||
: ledger.balance === "under-constrained"
|
||||
? "Manque de Fixed"
|
||||
: "Balance"}
|
||||
</span>
|
||||
|
||||
<span className="mono flex items-center gap-1.5 rounded border border-black/5 bg-white/70 px-2 py-0.5 font-medium">
|
||||
<span title="Équations">{ledger.nEquations}</span>
|
||||
<span className="text-[var(--ink-faint)]">
|
||||
{ledger.balance === "balanced"
|
||||
? "="
|
||||
: ledger.balance === "over-constrained"
|
||||
? ">"
|
||||
: "<"}
|
||||
</span>
|
||||
<span title="Inconnues">{ledger.nUnknowns}</span>
|
||||
</span>
|
||||
|
||||
<span className="hidden min-w-0 flex-1 truncate sm:inline text-[10px] opacity-90">
|
||||
{coach.headline}
|
||||
</span>
|
||||
|
||||
<span className="mono ml-auto hidden items-center gap-2 text-[10px] text-[var(--ink-faint)] md:flex">
|
||||
<Sparkles size={11} className="text-[var(--accent)]" />
|
||||
<span>{coach.tips.length} tip{coach.tips.length > 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nEdges} edges</span>
|
||||
<span>·</span>
|
||||
<span>{ledger.nBranches} ṁ</span>
|
||||
</span>
|
||||
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className={`ml-1 shrink-0 transition-transform ${open ? "" : "rotate-180"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="max-h-64 overflow-y-auto border-t border-black/5 bg-white/85 px-3 py-2 text-[10px] leading-relaxed text-[var(--ink-dim)]">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("guide")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "guide"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Guide
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("ledger")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
tab === "ledger"
|
||||
? "bg-[var(--ink)] text-white"
|
||||
: "bg-[var(--chrome-2)] text-[var(--ink-dim)]"
|
||||
}`}
|
||||
>
|
||||
Ledger
|
||||
</button>
|
||||
<span className="ml-auto text-[9px] text-[var(--ink-faint)]">
|
||||
Estimation UI — le CLI valide le ledger Rust exact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tab === "guide" ? (
|
||||
<>
|
||||
<p className="mb-2 text-[10px] text-[var(--ink-faint)]">
|
||||
Chaque <strong className="text-[var(--ink)]">Fixed</strong> doit libérer une
|
||||
inconnue ailleurs (Free P, actionneur, pression émergente…). Suit les étapes dans
|
||||
l’ordre.
|
||||
</p>
|
||||
<ol className="space-y-1.5">
|
||||
{coach.tips.map((tip, i) => (
|
||||
<li
|
||||
key={tip.id}
|
||||
className="flex gap-2 rounded border border-[var(--line)] bg-white px-2 py-1.5"
|
||||
>
|
||||
<span className="mono mt-0.5 w-4 shrink-0 text-[9px] font-bold text-[var(--ink-faint)]">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{tipIcon(tip.severity)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[var(--ink)]">{tip.title}</div>
|
||||
<div className="text-[var(--ink-dim)]">{tip.action}</div>
|
||||
{tip.focus && (
|
||||
<div className="mono mt-0.5 text-[9px] text-[var(--accent)]">
|
||||
→ {tip.focus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ledger.diagnostics.length > 0 && (
|
||||
<ul className="mb-2 space-y-0.5">
|
||||
{ledger.diagnostics.slice(0, 12).map((d, i) => (
|
||||
<li key={i} className="flex gap-1.5">
|
||||
<span className="text-amber-600">!</span>
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ledger.components.map((c) => (
|
||||
<div
|
||||
key={`${c.name}-${c.type}`}
|
||||
className="rounded border border-[var(--line)] bg-white px-2 py-1"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="mono font-semibold text-[var(--ink)]">{c.name}</span>
|
||||
<span className="mono text-[var(--ink-faint)]">{c.nEquations} eq</span>
|
||||
</div>
|
||||
<div className="mono truncate text-[9px] text-[var(--ink-faint)]">{c.type}</div>
|
||||
<div className="mt-0.5 truncate text-[9px] text-[var(--ink-dim)]">
|
||||
{c.roles.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user