Files
next-tjwater-frontend/src/features/agent/answer-ui/answer-view-state-provider.tsx
T

93 lines
5.8 KiB
TypeScript

/* eslint-disable react-refresh/only-export-components -- Local rendering adapter and its state hooks share private contexts. */
import { createContext, useContext, useLayoutEffect, useState, type ReactNode } from "react";
import { createStateStore, JSONUIProvider, useStateStore, type ComponentRegistry } from "@json-render/react";
import type { Spec } from "@json-render/core";
import {
answerStatePointer, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerNodeState, normalizeAnswerViewState,
type AnswerUiNodeState, type AnswerViewState, type AnswerViewStateChange
} from "./answer-view-state";
const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
}
export function withAnswerNodeIdentity(registry: ComponentRegistry): ComponentRegistry {
return Object.fromEntries(Object.entries(registry).map(([type, Component]) => [type, function AnswerNodeRenderer(props) {
const id = (props.element as typeof props.element & { __answerNodeId?: string }).__answerNodeId;
return <NodeContext.Provider value={id}><Component {...props} /></NodeContext.Provider>;
}]));
}
export function AnswerViewStateProvider({ spec, registry, viewState, onViewStateChange, children }: {
spec: Spec; registry: ComponentRegistry; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange; children: ReactNode;
}) {
const [store] = useState(() => createStateStore(normalizeAnswerViewState(spec, viewState)));
// Capture the full prior schema, including in-place updates from callers.
const [previous, setPrevious] = useState(() => JSON.stringify(spec));
const serialized = JSON.stringify(spec);
useLayoutEffect(() => {
const current = store.getSnapshot() as AnswerViewState;
const source = viewState ?? current;
const next = previous === serialized ? normalizeAnswerViewState(spec, source) : migrateAnswerViewState(JSON.parse(previous) as Spec, spec, source);
const dirty = answerViewStateDirtyPaths(source, next);
const previousSpec = JSON.parse(previous) as Spec;
const resetCharts = Object.entries(next.nodes).filter(([id, node]) => {
const old = source.nodes[id];
if (node.type !== "Chart" || old?.type !== "Chart" || previous === serialized) return false;
const before = previousSpec.elements[id], after = spec.elements[id];
if (before?.type !== "Chart" || JSON.stringify([before.props.x_data, before.props.series]) === JSON.stringify([after.props.x_data, after.props.series])) return false;
const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[];
const relocate = (position: number) => {
const label = previousLabels[position];
return label !== undefined && previousLabels.filter((value) => value === label).length === 1 && labels.filter((value) => value === label).length === 1 ? labels.indexOf(label) : -1;
};
return relocate(old.point) < 0 || relocate(old.range[0]) < 0 || relocate(old.range[1]) < relocate(old.range[0]);
}).map(([id]) => id);
store.update({ "/nodes": next.nodes, "/block": next.block });
if (previous !== serialized) {
store.set("/notices", Object.fromEntries(resetCharts.map((id) => [id, "数据标签已变化,原选点或区间无法唯一对应,已清除选择。仍可查看当前数据。"])));
setPrevious(serialized);
}
if (dirty.length) onViewStateChange?.(next, dirty);
}, [store, previous, serialized, spec, viewState, onViewStateChange]);
const change: AnswerViewStateChange = (next, dirty) => {
store.update({ "/nodes": next.nodes, "/block": next.block });
onViewStateChange?.(next, dirty);
};
return <ChangeContext.Provider value={change}>
<JSONUIProvider registry={registry} store={store}>{children}</JSONUIProvider>
</ChangeContext.Provider>;
}
export function useAnswerNodeState<T extends AnswerUiNodeState>(type: T["type"], props: Record<string, unknown>) {
const id = useContext(NodeContext);
const store = useStateStore();
const onChange = useContext(ChangeContext);
// Derived controls (such as a chart's data table) have no schema node of their own.
const [local, setLocal] = useState(() => normalizeAnswerNodeState({ type, props, children: [] }) as T);
const owned = id && (store.get(`/nodes/${answerStatePointer(id)}`) as AnswerUiNodeState | undefined)?.type === type;
const state = (owned ? normalizeAnswerNodeState({ type, props, children: [] }, store.get(`/nodes/${answerStatePointer(id!)}`)) : local) as T;
const patch = (updates: Partial<Omit<T, "type">>, fields?: string[]) => {
const live = owned ? store.get(`/nodes/${answerStatePointer(id!)}`) as T : state;
const next = { ...live, ...updates } as T;
if (!owned || !onChange) { setLocal(next); return; }
const snapshot = store.getSnapshot() as AnswerViewState;
onChange({ nodes: { ...snapshot.nodes, [id!]: next }, block: snapshot.block },
(fields ?? Object.keys(updates)).map((field) => `/nodes/${answerStatePointer(id!)}/${field}`));
};
return [state, patch, id ? store.get(`/notices/${answerStatePointer(id)}`) as string | undefined : undefined] as const;
}
export function useAnswerBlockState() {
const store = useStateStore(), onChange = useContext(ChangeContext);
const block = store.get("/block") as AnswerViewState["block"];
return [block, (evidenceFilter: string) => {
const snapshot = store.getSnapshot() as AnswerViewState;
onChange?.({ nodes: snapshot.nodes, block: { evidenceFilter } }, ["/block/evidenceFilter"]);
}] as const;
}