128 lines
8.9 KiB
TypeScript
128 lines
8.9 KiB
TypeScript
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<AnswerTableState, "type"> };
|
|
export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> };
|
|
export type AnswerEvidenceState = { type: "Evidence"; open: boolean };
|
|
export type AnswerUiNodeState = AnswerChartState | AnswerTableState | AnswerFormState | AnswerEvidenceState;
|
|
export type AnswerViewState = { nodes: Record<string, AnswerUiNodeState>; 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<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
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<string, unknown>)[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;
|
|
}
|