feat: integrate AMESim media models and editor UI

This commit is contained in:
ljz committed 2026-07-31 23:36:58 +08:00
1 parent 3e4f466114
commit e7177ab03e
181 files changed
+7042 -25270

No files matched your search

+484
View File
@@ -0,0 +1,484 @@
import {
Fragment,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type {
CSSProperties,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { createPortal } from "react-dom";
export type ParameterColumnLayout = {
nameWidth: number;
unitWidth: number;
};
export type ParameterTableRow = {
id: string;
name: string;
value: ReactNode;
unit?: ReactNode;
help?: string;
error?: string;
readOnly?: boolean;
description?: string;
defaultValue?: string;
range?: string;
currentUnit?: string;
};
type ParameterTableProps = {
ariaLabel: string;
rows: readonly ParameterTableRow[];
columnLayout: ParameterColumnLayout;
availableWidth: number;
onColumnLayoutChange: (layout: ParameterColumnLayout) => void;
};
type ColumnName = "name" | "unit";
type ColumnDragState = {
column: ColumnName;
pointerId: number;
startX: number;
startLayout: ParameterColumnLayout;
};
type HelpPreviewState = {
rowId: string;
left: number;
top: number;
transform: string;
};
type HelpPreviewAnchor = {
rowId: string;
clientX: number;
clientY: number;
};
export const PARAMETER_COLUMN_LAYOUT_KEY =
"system-simulation-flow:parameter-column-layout";
export const DEFAULT_PARAMETER_COLUMN_LAYOUT: ParameterColumnLayout = {
nameWidth: 112,
unitWidth: 72,
};
const MIN_NAME_WIDTH = 80;
const MAX_NAME_WIDTH = 220;
const MIN_VALUE_WIDTH = 96;
const MIN_UNIT_WIDTH = 48;
const MAX_UNIT_WIDTH = 112;
const COLUMN_KEYBOARD_STEP = 8;
const HELP_PREVIEW_DELAY_MS = 500;
const HELP_PREVIEW_GAP = 12;
const HELP_PREVIEW_MARGIN = 8;
const HELP_PREVIEW_MAX_WIDTH = 300;
const HELP_PREVIEW_ESTIMATED_HEIGHT = 156;
function boundedWidth(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
export function loadParameterColumnLayout(): ParameterColumnLayout {
try {
const parsed = JSON.parse(
localStorage.getItem(PARAMETER_COLUMN_LAYOUT_KEY) ?? "null",
) as Partial<ParameterColumnLayout> | null;
if (
!parsed ||
!Number.isFinite(parsed.nameWidth) ||
!Number.isFinite(parsed.unitWidth)
) {
return { ...DEFAULT_PARAMETER_COLUMN_LAYOUT };
}
return {
nameWidth: boundedWidth(Number(parsed.nameWidth), MIN_NAME_WIDTH, MAX_NAME_WIDTH),
unitWidth: boundedWidth(Number(parsed.unitWidth), MIN_UNIT_WIDTH, MAX_UNIT_WIDTH),
};
} catch {
return { ...DEFAULT_PARAMETER_COLUMN_LAYOUT };
}
}
export function saveParameterColumnLayout(layout: ParameterColumnLayout) {
try {
localStorage.setItem(PARAMETER_COLUMN_LAYOUT_KEY, JSON.stringify(layout));
} catch {
// Column resizing remains available when browser storage is unavailable.
}
}
function effectiveColumnLayout(
preferred: ParameterColumnLayout,
availableWidth: number,
): ParameterColumnLayout {
const usableWidth = Number.isFinite(availableWidth)
? Math.max(MIN_NAME_WIDTH + MIN_VALUE_WIDTH + MIN_UNIT_WIDTH, availableWidth)
: MIN_NAME_WIDTH + MIN_VALUE_WIDTH + MIN_UNIT_WIDTH;
let nameWidth = boundedWidth(preferred.nameWidth, MIN_NAME_WIDTH, MAX_NAME_WIDTH);
let unitWidth = boundedWidth(preferred.unitWidth, MIN_UNIT_WIDTH, MAX_UNIT_WIDTH);
let overflow = nameWidth + MIN_VALUE_WIDTH + unitWidth - usableWidth;
if (overflow > 0) {
const unitReduction = Math.min(overflow, unitWidth - MIN_UNIT_WIDTH);
unitWidth -= unitReduction;
overflow -= unitReduction;
}
if (overflow > 0) {
nameWidth -= Math.min(overflow, nameWidth - MIN_NAME_WIDTH);
}
return { nameWidth, unitWidth };
}
function rowHasHelpPreview(row: ParameterTableRow) {
return Boolean(
row.description ||
(typeof row.help === "string" && row.help) ||
row.defaultValue ||
row.range ||
row.currentUnit,
);
}
function helpPreviewPosition(clientX: number, clientY: number) {
const placeOnRight =
clientX + HELP_PREVIEW_GAP + HELP_PREVIEW_MAX_WIDTH <=
window.innerWidth - HELP_PREVIEW_MARGIN;
const placeBelow =
clientY + HELP_PREVIEW_GAP + HELP_PREVIEW_ESTIMATED_HEIGHT <=
window.innerHeight - HELP_PREVIEW_MARGIN;
return {
left: clientX + (placeOnRight ? HELP_PREVIEW_GAP : -HELP_PREVIEW_GAP),
top: clientY + (placeBelow ? HELP_PREVIEW_GAP : -HELP_PREVIEW_GAP),
transform: `translate(${placeOnRight ? "0" : "-100%"}, ${
placeBelow ? "0" : "-100%"
})`,
};
}
export function ParameterTable({
ariaLabel,
rows,
columnLayout,
availableWidth,
onColumnLayoutChange,
}: ParameterTableProps) {
const effectiveLayout = effectiveColumnLayout(columnLayout, availableWidth);
const columnDragRef = useRef<ColumnDragState | null>(null);
const helpPreviewTimerRef = useRef<number | null>(null);
const helpPreviewAnchorRef = useRef<HelpPreviewAnchor | null>(null);
const [activeColumnResize, setActiveColumnResize] =
useState<ColumnName | null>(null);
const [helpPreview, setHelpPreview] = useState<HelpPreviewState | null>(null);
const dismissHelpPreview = useCallback(() => {
if (helpPreviewTimerRef.current !== null) {
window.clearTimeout(helpPreviewTimerRef.current);
helpPreviewTimerRef.current = null;
}
helpPreviewAnchorRef.current = null;
setHelpPreview(null);
}, []);
const scheduleHelpPreview = useCallback(
(
target: HTMLElement,
row: ParameterTableRow,
pointer?: { clientX: number; clientY: number },
) => {
dismissHelpPreview();
if (!rowHasHelpPreview(row)) {
return;
}
const bounds = target.getBoundingClientRect();
const fallbackAnchor = {
clientX: bounds.left + bounds.width / 2,
clientY: bounds.top + bounds.height / 2,
};
helpPreviewAnchorRef.current = {
rowId: row.id,
...(pointer ?? fallbackAnchor),
};
helpPreviewTimerRef.current = window.setTimeout(() => {
helpPreviewTimerRef.current = null;
const anchor =
helpPreviewAnchorRef.current?.rowId === row.id
? helpPreviewAnchorRef.current
: { rowId: row.id, ...fallbackAnchor };
setHelpPreview({
rowId: row.id,
...helpPreviewPosition(anchor.clientX, anchor.clientY),
});
}, HELP_PREVIEW_DELAY_MS);
},
[dismissHelpPreview],
);
const trackHelpPointer = (
event: ReactMouseEvent<HTMLTableRowElement>,
row: ParameterTableRow,
) => {
helpPreviewAnchorRef.current = {
rowId: row.id,
clientX: event.clientX,
clientY: event.clientY,
};
};
useEffect(() => {
const dismissOnViewportChange = () => dismissHelpPreview();
window.addEventListener("scroll", dismissOnViewportChange, true);
window.addEventListener("resize", dismissOnViewportChange);
return () => {
window.removeEventListener("scroll", dismissOnViewportChange, true);
window.removeEventListener("resize", dismissOnViewportChange);
if (helpPreviewTimerRef.current !== null) {
window.clearTimeout(helpPreviewTimerRef.current);
helpPreviewTimerRef.current = null;
}
};
}, [dismissHelpPreview]);
const changeColumnLayout = (
column: ColumnName,
boundaryDelta: number,
startLayout = effectiveLayout,
) => {
const proposed =
column === "name"
? { ...startLayout, nameWidth: startLayout.nameWidth + boundaryDelta }
: { ...startLayout, unitWidth: startLayout.unitWidth - boundaryDelta };
onColumnLayoutChange(effectiveColumnLayout(proposed, availableWidth));
};
const startColumnResize = (
event: ReactPointerEvent<HTMLDivElement>,
column: ColumnName,
) => {
dismissHelpPreview();
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
columnDragRef.current = {
column,
pointerId: event.pointerId,
startX: event.clientX,
startLayout: effectiveLayout,
};
setActiveColumnResize(column);
};
const moveColumnResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = columnDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
changeColumnLayout(drag.column, event.clientX - drag.startX, drag.startLayout);
};
const stopColumnResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (columnDragRef.current?.pointerId !== event.pointerId) {
return;
}
columnDragRef.current = null;
setActiveColumnResize(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const resizeColumnWithKeyboard = (
event: ReactKeyboardEvent<HTMLDivElement>,
column: ColumnName,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
dismissHelpPreview();
const step = event.shiftKey ? COLUMN_KEYBOARD_STEP * 3 : COLUMN_KEYBOARD_STEP;
changeColumnLayout(column, event.key === "ArrowRight" ? step : -step);
};
const nameColumnMaximum = Math.max(
MIN_NAME_WIDTH,
Math.min(MAX_NAME_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.unitWidth),
);
const unitColumnMaximum = Math.max(
MIN_UNIT_WIDTH,
Math.min(MAX_UNIT_WIDTH, availableWidth - MIN_VALUE_WIDTH - effectiveLayout.nameWidth),
);
const tableStyle = {
"--parameter-name-width": `${effectiveLayout.nameWidth}px`,
"--parameter-unit-width": `${effectiveLayout.unitWidth}px`,
} as CSSProperties;
const previewRow = helpPreview
? rows.find((row) => row.id === helpPreview.rowId) ?? null
: null;
const previewDescription = previewRow?.description ||
(typeof previewRow?.help === "string" ? previewRow.help : "");
const renderColumnResizer = (column: ColumnName) => {
const currentWidth =
column === "name" ? effectiveLayout.nameWidth : effectiveLayout.unitWidth;
const minimum = column === "name" ? MIN_NAME_WIDTH : MIN_UNIT_WIDTH;
const maximum = column === "name" ? nameColumnMaximum : unitColumnMaximum;
const label = column === "name" ? "调整参数名列宽" : "调整单位列宽";
return (
<div
aria-label={label}
aria-orientation="vertical"
aria-valuemax={Math.round(maximum)}
aria-valuemin={minimum}
aria-valuenow={Math.round(currentWidth)}
aria-valuetext={`${column === "name" ? "参数名" : "单位"}列 ${Math.round(currentWidth)} 像素`}
className="parameter-table-column-resizer"
data-column={column}
onDoubleClick={() => {
dismissHelpPreview();
onColumnLayoutChange({ ...DEFAULT_PARAMETER_COLUMN_LAYOUT });
}}
onKeyDown={(event) => resizeColumnWithKeyboard(event, column)}
onLostPointerCapture={stopColumnResize}
onPointerCancel={stopColumnResize}
onPointerDown={(event) => startColumnResize(event, column)}
onPointerMove={moveColumnResize}
onPointerUp={stopColumnResize}
role="separator"
tabIndex={0}
/>
);
};
const rowBlurred = (event: ReactFocusEvent<HTMLTableRowElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
dismissHelpPreview();
}
};
return (
<div
className={`parameter-table-wrap ${activeColumnResize ? `resizing-${activeColumnResize}` : ""}`}
data-resizing-column={activeColumnResize ?? undefined}
>
<table aria-label={ariaLabel} className="parameter-table" style={tableStyle}>
<colgroup>
<col style={{ width: effectiveLayout.nameWidth }} />
<col />
<col style={{ width: effectiveLayout.unitWidth }} />
</colgroup>
<thead className="parameter-table-head">
<tr className="parameter-table-row parameter-table-head-row">
<th className="parameter-table-cell parameter-table-name-cell" scope="col">
参数
{renderColumnResizer("name")}
</th>
<th className="parameter-table-cell parameter-table-value-cell" scope="col">
值
{renderColumnResizer("unit")}
</th>
<th className="parameter-table-cell parameter-table-unit-cell" scope="col">
单位
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const hasPreview = rowHasHelpPreview(row);
const hasUnit = row.unit !== undefined && row.unit !== null;
return (
<Fragment key={row.id}>
<tr
aria-describedby={
helpPreview?.rowId === row.id
? "parameter-help-preview"
: undefined
}
className={`parameter-table-row ${row.error ? "invalid" : ""} ${hasUnit ? "" : "unitless"} ${row.readOnly ? "read-only" : ""}`}
onBlurCapture={rowBlurred}
onFocusCapture={(event) =>
scheduleHelpPreview(event.currentTarget, row)
}
onMouseEnter={(event) =>
scheduleHelpPreview(event.currentTarget, row, {
clientX: event.clientX,
clientY: event.clientY,
})
}
onMouseLeave={dismissHelpPreview}
onMouseMove={(event) => trackHelpPointer(event, row)}
tabIndex={row.readOnly && hasPreview ? 0 : undefined}
>
<th
className="parameter-table-cell parameter-table-name-cell"
scope="row"
>
{row.name}
</th>
<td className="parameter-table-cell parameter-table-value-cell">
{row.value}
</td>
<td className="parameter-table-cell parameter-table-unit-cell">
{hasUnit ? row.unit : <span aria-label="无单位">—</span>}
</td>
</tr>
{row.error ? (
<tr className="parameter-table-message-row">
<td className="parameter-table-cell" colSpan={3}>
<small className="field-error">{row.error}</small>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
{helpPreview && previewRow && typeof document !== "undefined"
? createPortal(
<div
className="parameter-help-preview"
id="parameter-help-preview"
role="tooltip"
style={{
left: helpPreview.left,
position: "fixed",
top: helpPreview.top,
transform: helpPreview.transform,
}}
>
<strong className="parameter-help-preview-title">
{previewRow.name}
</strong>
{previewDescription ? (
<span className="parameter-help-preview-description">
{previewDescription}
</span>
) : null}
{previewRow.defaultValue ||
previewRow.range ||
previewRow.currentUnit ? (
<span className="parameter-help-preview-meta">
{previewRow.defaultValue ? (
<span>默认值:{previewRow.defaultValue}</span>
) : null}
{previewRow.range ? <span>范围:{previewRow.range}</span> : null}
{previewRow.currentUnit ? (
<span>当前单位:{previewRow.currentUnit}</span>
) : null}
</span>
) : null}
</div>,
document.body,
)
: null}
</div>
);
}