/* 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(undefined); const ChangeContext = createContext(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 ; }])); } 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 {children} ; } export function useAnswerNodeState(type: T["type"], props: Record) { 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>, 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; }