feat(export): generalized buildMatrix over assignable dimensions

This commit is contained in:
Experiments DB Dev
2026-07-19 13:22:53 -04:00
parent 7d112de0d7
commit 600323f40a
2 changed files with 99 additions and 83 deletions
+47 -43
View File
@@ -148,53 +148,57 @@ function dateKey(date) {
return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD
}
// Pivot statuses into { columns, rows }.
// columns: [{ id, header }] — all animals, ordered by name, headers
// disambiguated with animal_id_string on duplicate names.
// rows: [{ date, values }] — one per date that has ≥1 value; values are
// aligned to columns, blank cells are '' (empty string).
export function buildMatrix(statuses, animals, param) {
const sortedAnimals = [...(animals ?? [])].sort((a, b) =>
String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')),
);
// Pivot statuses into a 2-D matrix per the chosen row/column dimensions.
// The leftover (pinned) dimension is fixed to pinnedMember. Every cell resolves
// a (dateKey, animalId, param) triple through the status index.
export function buildMatrix(config, ctx) {
const { rowDim, colDim, pinnedMember, groupField } = config;
const pinnedDim = otherDimension(rowDim, colDim);
const index = buildStatusIndex(ctx.statuses);
const full = { ...ctx, groupField };
const rowMembers = listDimensionMembers(rowDim, full);
const colMembers = listDimensionMembers(colDim, full);
const nameCounts = new Map();
for (const a of sortedAnimals) {
const n = a.animal_name ?? '';
nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1);
}
const columns = sortedAnimals.map((a) => {
const name = a.animal_name ?? '';
const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name;
return { id: a.id, header };
});
const colIndex = new Map(columns.map((c, i) => [c.id, i]));
const cellValue = (rowM, colM) => {
const byDim = { [rowDim]: rowM, [colDim]: colM, [pinnedDim]: pinnedMember };
const dk = byDim.date?.dateKey ?? null;
const animalId = byDim.subject?.animalId ?? null;
const param = byDim.parameter?.param ?? null;
if (dk === null || animalId === null || animalId === undefined || !param) return '';
const status = index.get(dk)?.get(animalId);
if (!status) return '';
const v = extractValue(status, param);
return hasValue(v) ? v : '';
};
// cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins.
const cells = new Map();
const datesWithData = new Set();
const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
for (const s of ordered) {
if (!colIndex.has(s.animal_id)) continue;
const dk = dateKey(s.date);
if (!cells.has(dk)) cells.set(dk, new Map());
const byAnimal = cells.get(dk);
if (byAnimal.has(s.animal_id)) continue; // first wins
const v = extractValue(s, param);
byAnimal.set(s.animal_id, v);
if (hasValue(v)) datesWithData.add(dk);
}
let rows = rowMembers.map((rowM) => ({
member: rowM,
values: colMembers.map((colM) => cellValue(rowM, colM)),
}));
const rows = [...datesWithData].sort().map((dk) => {
const byAnimal = cells.get(dk);
const values = columns.map((c) => {
const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null;
return hasValue(v) ? v : '';
});
return { date: dk, values };
});
// Empty-member rule: keep all subjects; drop entirely-blank date/parameter rows & columns.
if (rowDim !== 'subject') rows = rows.filter((r) => r.values.some((v) => v !== ''));
return { columns, rows };
let keptCols = colMembers.map((_, i) => i);
if (colDim !== 'subject') keptCols = keptCols.filter((i) => rows.some((r) => r.values[i] !== ''));
const columns = keptCols.map((i) => colMembers[i]);
rows = rows.map((r) => ({ member: r.member, values: keptCols.map((i) => r.values[i]) }));
const grouped = !!groupField && groupField !== '__none__';
const groupAxis = grouped
? (rowDim === 'subject' ? 'row' : colDim === 'subject' ? 'col' : null)
: null;
return {
rowDim,
colDim,
pinnedDim,
corner: dimLabel(rowDim),
context: { label: dimLabel(pinnedDim), value: pinnedMember?.label ?? '' },
groupAxis,
columns,
rows,
};
}
function escapeCsvCell(value) {