import type { Spec } from "@json-render/core"; // `selection` is an empty legacy v1 wire field, never an interactive row selection. export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] }; export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit }; export type AnswerFormState = { type: "FollowUpForm"; values: Record }; export type AnswerEvidenceState = { type: "Evidence"; open: boolean }; export type AnswerUiNodeState = AnswerChartState | AnswerTableState | AnswerFormState | AnswerEvidenceState; export type AnswerViewState = { nodes: Record; block: { evidenceFilter: string } }; export type AnswerViewStateChange = (next: AnswerViewState, dirtyPaths: string[]) => void; export type AnswerViewStateRecord = AnswerViewState & { version: "answer-view-state@1"; message_id: string; block_id: string; revision: number; schema_digest: string; updated_at: string | null; }; type Node = Spec["elements"][string]; const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const indices = (value: unknown, length: number) => Array.isArray(value) ? [...new Set(value.filter((index): index is number => Number.isInteger(index) && index >= 0 && index < length))].slice(0, 100) : []; const index = (value: unknown, length: number, fallback = 0) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < length ? value : fallback; export const answerStatePointer = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1"); /** Only the local component allowlist can contribute state. Model props stay unchanged. */ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiNodeState | undefined { const value = record(input); const state = value.type === node.type ? value : {}; if (node.type === "Chart") { const labels = node.props.x_data as string[]; const series = node.props.series as unknown[]; const proposed = Array.isArray(state.range) ? state.range : []; const start = index(proposed[0], labels.length); const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1)); const hidden = indices(state.hidden, series.length); const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar", hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true }; const data = answerChartTableData(node, chart); const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState; const { type: _type, ...tableState } = table; chart.table = tableState; return chart; } if (node.type === "Table") { const columns = node.props.columns as unknown[]; const sort = record(state.sort); return { type: "Table", query: typeof state.query === "string" ? state.query : "", sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null, selection: [] }; } if (node.type === "FollowUpForm") { const values = record(state.values); return { type: "FollowUpForm", values: Object.fromEntries((node.props.fields as Array<{ name: string }>).filter(({ name }) => typeof values[name] === "string").map(({ name }) => [name, String(values[name])])) }; } if (node.type === "Evidence") return { type: "Evidence", open: state.open === true }; } export function answerChartTableData(node: Node, state: AnswerChartState) { const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>; const visible = series.filter((_, index) => !state.hidden.includes(index)); return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) }; } export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState { const state = record(input), nodes = record(state.nodes), block = record(state.block); const kinds = Object.values(spec.elements).filter((node) => node.type === "Evidence").map((node) => node.props.kind); return { nodes: Object.fromEntries(Object.entries(spec.elements).flatMap(([id, node]) => { const value = normalizeAnswerNodeState(node, nodes[id]); return value ? [[id, value]] : []; })), block: { evidenceFilter: typeof block.evidenceFilter === "string" && kinds.includes(block.evidenceFilter) ? block.evidenceFilter : "all" } }; } /** Migrate by explicit data identity, never by assuming old indices identify new data. */ export function migrateAnswerViewState(previous: Spec, next: Spec, input: AnswerViewState): AnswerViewState { const state = normalizeAnswerViewState(next, input); for (const [id, value] of Object.entries(state.nodes)) { const before = previous.elements[id], after = next.elements[id], old = input.nodes[id]; if (!before || before.type !== after.type || !old || old.type !== value.type) continue; if (value.type === "Table") { if (!same(before.props.columns, after.props.columns)) value.sort = null; } if (value.type === "FollowUpForm") { const fields = before.props.fields as Array<{ name: string; kind: string }>; const nextFields = after.props.fields as Array<{ name: string; kind: string }>; value.values = Object.fromEntries(Object.entries(value.values).filter(([name]) => fields.some((field) => field.name === name && nextFields.some((nextField) => nextField.name === name && nextField.kind === field.kind)))); } if (value.type === "Chart" && old.type === "Chart") { const names = (node: Node) => (node.props.series as Array<{ name: string }>).map(({ name }) => name); if (!same(names(before), names(after))) value.hidden = []; const previousLabels = before.props.x_data as string[], labels = after.props.x_data as string[]; if (!same(previousLabels, labels) || !same(before.props.series, after.props.series)) { const relocate = (position: number) => { const label = previousLabels[position]; return label !== undefined && previousLabels.filter((item) => item === label).length === 1 && labels.filter((item) => item === label).length === 1 ? labels.indexOf(label) : -1; }; const point = relocate(old.point), start = relocate(old.range[0]), end = relocate(old.range[1]); if (point < 0 || start < 0 || end < start) { value.point = 0; value.range = [0, labels.length - 1]; value.inspecting = false; } else { value.point = point; value.range = [start, end]; } } if (value.table) { const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value); if (!same(oldData.columns, newData.columns)) value.table.sort = null; } } } return state; } export function answerViewStateDirtyPaths(before: AnswerViewState, after: AnswerViewState): string[] { const paths: string[] = []; for (const [id, value] of Object.entries(after.nodes)) { const old = before.nodes[id]; if (old && old.type !== value.type) { paths.push(`/nodes/${answerStatePointer(id)}`); continue; } for (const [field, next] of Object.entries(value)) { if (field === "type") continue; const prefix = `/nodes/${answerStatePointer(id)}/${field}`; if (field === "values" && value.type === "FollowUpForm") { const oldValues = old?.type === "FollowUpForm" ? old.values : {}; for (const name of new Set([...Object.keys(oldValues), ...Object.keys(value.values)])) if (oldValues[name] !== value.values[name]) paths.push(`${prefix}/${answerStatePointer(name)}`); } else if (field === "table" && value.type === "Chart" && value.table) { const oldTable = old?.type === "Chart" ? old.table : undefined; for (const [key, item] of Object.entries(value.table)) if (!same(oldTable?.[key as keyof typeof oldTable], item)) paths.push(`${prefix}/${key}`); } else if (!same(old && (old as unknown as Record)[field], next)) paths.push(prefix); } } if (before.block.evidenceFilter !== after.block.evidenceFilter) paths.push("/block/evidenceFilter"); for (const id of Object.keys(before.nodes)) if (!Object.hasOwn(after.nodes, id)) paths.push(`/nodes/${answerStatePointer(id)}`); return paths; }